@aglyn/plugins-marketing 1.0.0-beta.222 → 1.0.0-beta.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-detail-card.js +17 -3
  3. package/src/lib/components/campaign-detail-card.js.map +1 -1
  4. package/src/lib/components/campaign-edit-drawer.d.ts +6 -0
  5. package/src/lib/components/campaign-edit-drawer.js +13 -2
  6. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  7. package/src/lib/components/campaign-queries.d.ts +5 -0
  8. package/src/lib/components/campaign-queries.js +7 -0
  9. package/src/lib/components/campaign-queries.js.map +1 -1
  10. package/src/lib/components/campaign-reach-sections.d.ts +28 -0
  11. package/src/lib/components/campaign-reach-sections.js +95 -4
  12. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  13. package/src/lib/components/conversion-attribution.component.d.ts +18 -0
  14. package/src/lib/components/conversion-attribution.component.js +177 -39
  15. package/src/lib/components/conversion-attribution.component.js.map +1 -1
  16. package/src/lib/components/record-attribution-widget.d.ts +5 -0
  17. package/src/lib/components/record-attribution-widget.js +6 -3
  18. package/src/lib/components/record-attribution-widget.js.map +1 -1
  19. package/src/lib/components/site-runtime.d.ts +5 -0
  20. package/src/lib/components/site-runtime.js +101 -2
  21. package/src/lib/components/site-runtime.js.map +1 -1
  22. package/src/lib/declarations.server.js +17 -1
  23. package/src/lib/declarations.server.js.map +1 -1
  24. package/src/lib/model/campaign-container.d.ts +28 -0
  25. package/src/lib/model/campaign-container.js +34 -0
  26. package/src/lib/model/campaign-container.js.map +1 -1
  27. package/src/lib/model/campaign-conversions.d.ts +47 -3
  28. package/src/lib/model/campaign-conversions.js +49 -4
  29. package/src/lib/model/campaign-conversions.js.map +1 -1
  30. package/src/lib/model/campaign-visits.d.ts +74 -0
  31. package/src/lib/model/campaign-visits.js +65 -0
  32. package/src/lib/model/campaign-visits.js.map +1 -0
  33. package/src/lib/server/campaign-conversion-attribution.d.ts +34 -3
  34. package/src/lib/server/campaign-conversion-attribution.js +126 -24
  35. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  36. package/src/lib/server/campaign-touch-targets.d.ts +61 -0
  37. package/src/lib/server/campaign-touch-targets.js +162 -0
  38. package/src/lib/server/campaign-touch-targets.js.map +1 -0
  39. package/src/lib/server/campaign-visit-beacon.d.ts +44 -0
  40. package/src/lib/server/campaign-visit-beacon.js +125 -0
  41. package/src/lib/server/campaign-visit-beacon.js.map +1 -0
  42. package/src/lib/server/conversion-creditor.js +109 -2
  43. package/src/lib/server/conversion-creditor.js.map +1 -1
  44. package/src/lib/server/email-events.js +24 -25
  45. package/src/lib/server/email-events.js.map +1 -1
  46. package/src/lib/server/site-page-enricher.js +18 -2
  47. package/src/lib/server/site-page-enricher.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-container.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 CAMPAIGN IS A CONTAINER; A SEND IS ONE MESSAGE INSIDE IT.\n *\n * ## Both belong to the ORGANIZATION (AGL-3273)\n *\n * `orgs/{orgId}/emailCampaigns/{campaignId}` holds a CONTAINER: a name, a\n * window, the lists it aims at. `orgs/{orgId}/campaigns/{sendId}` holds a\n * SEND: one subject, one body, one audience, one set of counters.\n *\n * They lived under one site (`hosts/{hostId}/…`) until the audience side —\n * contacts, lists, topics, sending domains — had long since become the\n * org's. An organization's outbound sequences then had to be filed under a\n * campaign that belonged to its marketing site, and the org-level Leads page\n * could not name a campaign at all. Moving both up is what lets one campaign\n * coordinate a push across every site, a sequence and the leads it produced.\n *\n * ## What stays a SITE fact, and where it is recorded\n *\n * A campaign's reach is {@link EmailCampaign.visibleTo}, the same scope\n * tokens every org-owned resource carries: `['org']` is every site, and\n * `['host:{id}', …]` is the sites it is placed on. It decides which sites'\n * pickers offer the campaign and which scoped collaborators can read it —\n * nothing else, because membership stays a field on the member.\n *\n * A send, by contrast, is always sent AS one site: that site's sender, the\n * consent the person gave that site, the site's unsubscribe signature and\n * its designs. {@link CAMPAIGN_SEND_HOST_FIELD} records which, and the\n * send's `visibleTo` is that one site's token.\n *\n * ## Ids did not change, and that is load-bearing\n *\n * Every unsubscribe link that has ever gone out carries `hostId` and\n * `cid={sendId}`, both inside the link's HMAC, and those emails sit in\n * inboxes forever. So the move copied every document under its existing id,\n * and every reader that starts from a link resolves the send through the\n * site's org (`resolveCampaignSendRef` in the Marketing plugin), with the\n * old site path as the fallback for a document the migration has not reached.\n * A member's `campaignIds` names container ids, which likewise never moved.\n *\n * A send written before containers existed carries no\n * {@link CAMPAIGN_SEND_CONTAINER_FIELD}, and {@link campaignListRows}\n * presents it as a container of one rather than hiding it.\n *\n * ## Why the arithmetic is here\n *\n * The same reason `campaign-report.ts` gives for the per-send rates: a\n * denominator chosen in JSX is a denominator nobody tests. Aggregating across\n * sends adds one problem the single-send report does not have — some sends\n * recorded a field and others never did — and summing those into one number\n * silently reports a partial total as a complete one. Every aggregate here\n * therefore reports how many sends it could measure.\n */\n\nimport {\n readListUnsubscribeSetting,\n type ListUnsubscribeForcedReason,\n} from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n sendRate,\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { CAMPAIGN_SEQUENCE_REPORTS_COLLECTION } from './campaign-report'\n\n/** The org collection holding campaign CONTAINERS. */\nexport const EMAIL_CAMPAIGNS_COLLECTION = 'emailCampaigns'\n\n/** The org collection holding SENDS — one document per email. */\nexport const CAMPAIGN_SENDS_COLLECTION = 'campaigns'\n\n/** `orgs/{orgId}/emailCampaigns`, as path segments. */\nexport function orgEmailCampaignsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, EMAIL_CAMPAIGNS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaigns`, as path segments. */\nexport function orgCampaignSendsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SENDS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaignSequenceReports`, as path segments. */\nexport function orgCampaignSequenceReportsPath(\n orgId: string,\n): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION]\n}\n\n/**\n * The field on a SEND naming the site it is sent AS.\n *\n * Required on every send written since AGL-3273. The sender, the consent\n * group, the unsubscribe signature, the designs and the experiments all\n * belong to that site, so a send with no site cannot be delivered.\n */\nexport const CAMPAIGN_SEND_HOST_FIELD = 'hostId'\n\n/** The scope token naming every site in the org, as `scope-tokens.ts` spells it. */\nexport const CAMPAIGN_ORG_SCOPE = 'org'\n\n/** `host:{hostId}`, the scope token naming one site. */\nexport function campaignHostScope(hostId: string): string {\n return `host:${hostId}`\n}\n\n/**\n * The `visibleTo` a send is stamped with: the one site it sends as.\n *\n * Not `['org']`. An org-wide member reads every send regardless, and a\n * collaborator invited to a sibling site has no business reading who this\n * site mailed.\n */\nexport function campaignSendVisibleTo(hostId: string): string[] {\n return [campaignHostScope(hostId)]\n}\n\n/**\n * The sites a container is placed on, or `null` when it is on every site.\n *\n * An absent or empty `visibleTo` reads as NO sites, for the reason\n * `visibleToHost` in `scope-tokens.ts` gives: a document nobody scoped is\n * not the same fact as one scoped to everyone, and hiding is the\n * recoverable direction.\n */\nexport function campaignSiteIds(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n): string[] | null {\n const tokens = campaign?.visibleTo ?? []\n if (tokens.includes(CAMPAIGN_ORG_SCOPE)) return null\n const ids: string[] = []\n for (const token of tokens) {\n if (typeof token === 'string' && token.startsWith('host:') && token.length > 5) {\n ids.push(token.slice(5))\n }\n }\n return ids\n}\n\n/** Whether a container is offered on this site. */\nexport function campaignPlacedOnHost(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n hostId: string,\n): boolean {\n const ids = campaignSiteIds(campaign)\n return ids === null || ids.includes(hostId)\n}\n\n/**\n * The `visibleTo` a container is stamped with, from a site selection.\n *\n * `null` (or an empty selection) is every site. A selection is deduped and\n * capped at thirty, the ceiling `array-contains-any` and the rules' `hasAny`\n * both impose.\n */\nexport function campaignVisibleTo(siteIds: readonly string[] | null): string[] {\n if (!siteIds || siteIds.length === 0) return [CAMPAIGN_ORG_SCOPE]\n const unique = [...new Set(siteIds.filter((id) => typeof id === 'string' && id))]\n return unique.slice(0, 30).map(campaignHostScope)\n}\n\n/**\n * The field on a SEND naming the campaign it belongs to.\n *\n * Not `campaignId`: on a send document that name already means the send's own\n * id — it is what `cid` carries and what the report route addresses — and one\n * word meaning both would be read into the other on the first edit.\n */\nexport const CAMPAIGN_SEND_CONTAINER_FIELD = 'emailCampaignId'\n\n/**\n * A campaign: the container, not a message.\n *\n * Stored at `orgs/{orgId}/emailCampaigns/{campaignId}`.\n */\nexport interface EmailCampaign {\n $id: string\n /** What the merchant called it. */\n name: string\n /** When the campaign window opens. Null for a campaign with no dates. */\n startAtMs?: number | null\n /** When it closes. Null for an open-ended campaign. */\n endAtMs?: number | null\n /** Org email lists this campaign is aimed at, by list id. */\n listIds?: string[]\n /**\n * The stream this campaign's emails open on.\n *\n * A DEFAULT, not a constraint. The topic decides who a send skips, what the\n * preference page linked from the footer highlights, and which stream a\n * resulting opt-out is recorded against — all facts about one MESSAGE, and\n * one campaign may legitimately carry a newsletter and a promotion. So the\n * composer's picker is what the send records; this is what the picker opens\n * on, which is what stops a \"Sales\" campaign quietly mailing under\n * `marketing`.\n */\n topicId?: string\n /**\n * The sites the campaign is placed on, as scope tokens: `['org']` for\n * every site, `['host:{id}', …]` for a set. See {@link campaignSiteIds}.\n */\n visibleTo?: string[]\n /**\n * Whether its emails carry `List-Unsubscribe` and `List-Unsubscribe-Post`\n * (RFC 8058), the header mail clients draw their own Unsubscribe button\n * from (AGL-3307). ON unless stored `false` — absent on every campaign\n * written before the setting existed, which read as on because that is\n * what they always did. Read it through {@link campaignListUnsubscribe}.\n *\n * Off is overridden per send by the bulk guard; see\n * {@link CampaignListUnsubscribeRecord}. The footer's unsubscribe link is\n * in every email either way.\n */\n listUnsubscribe?: boolean\n createdAtMs?: number\n createdBy?: string\n deletedAt?: unknown\n}\n\n/**\n * Whether a campaign's emails carry the mail-client unsubscribe button:\n * the shared reader sequences use, with a campaign's default of ON.\n */\nexport function campaignListUnsubscribe(\n campaign: Pick<EmailCampaign, 'listUnsubscribe'> | null | undefined,\n): boolean {\n return readListUnsubscribeSetting(campaign?.listUnsubscribe, true)\n}\n\n/**\n * What one SEND did with the List-Unsubscribe header, written by the\n * campaign sender on every batch (AGL-3307).\n */\nexport interface CampaignListUnsubscribeRecord {\n /** The campaign's setting when it went out. */\n requested?: boolean\n /** Whether the header was on the messages. */\n on?: boolean\n /** True when the setting said off and the bulk guard turned it back on. */\n forced?: boolean\n reason?: ListUnsubscribeForcedReason\n /** One sentence saying why, for the campaign page. */\n detail?: string\n /** Campaign messages in the day, this email included, it was judged on. */\n volume?: number\n /** The threshold it was judged against. */\n threshold?: number\n atMs?: number\n}\n\n/** The sends among these whose header the bulk guard forced back on. */\nexport function campaignForcedListUnsubscribeSends<\n T extends Pick<CampaignSend, 'listUnsubscribe'>,\n>(sends: readonly T[] | null | undefined): T[] {\n return (sends ?? []).filter((send) => send?.listUnsubscribe?.forced === true)\n}\n\n/** One send, as much of it as a list or a rollup needs. */\nexport interface CampaignSend {\n $id: string\n subject?: string\n /** The audience KIND: `'leads'`, `'members'`, `'segment'`, `'list'`. */\n audience?: string\n /** The list this send addressed, when the kind is `'list'`. */\n listId?: string\n /** The segment this send addressed, when the kind is `'segment'`. */\n segmentId?: string\n /** Which container it belongs to, absent on a send written before them. */\n emailCampaignId?: string\n /** The site it is sent as. See {@link CAMPAIGN_SEND_HOST_FIELD}. */\n hostId?: string\n status?: string\n sentAt?: { seconds?: number } | null\n sendAtMs?: number\n /**\n * When the record was minted, stamped by every writer that creates one.\n *\n * Absent on a send written before the stamp existed. The lists that draw\n * drafts beside sends order on it through `emailListTimeMs`, which is why\n * it is here rather than only on the loose record shape: a draft carries\n * neither `sentAt` nor `sendAtMs`, so this is the only time it has.\n */\n createdAtMs?: number\n stats?: SendStats\n /** How far a send that goes out over several batches has got. */\n resume?: CampaignResume\n /** What it did with the List-Unsubscribe header. Absent before AGL-3307. */\n listUnsubscribe?: CampaignListUnsubscribeRecord\n /**\n * Staff review of a send the outbound phishing screen held (AGL-3356).\n * Written by the send core, decided only by staff. See\n * {@link campaignSendHeldForReview}.\n */\n staffReview?: CampaignStaffReview\n}\n\n/** A held send's review, as the send core and the staff decision write it. */\nexport interface CampaignStaffReview {\n /** `held` until staff decide; then `released` or `rejected`. */\n state?: string\n /** `HS-…`, the reference the merchant quotes to support. */\n reference?: string\n reviewId?: string\n heldAtMs?: number\n}\n\n/**\n * Is this send waiting on a staff review (AGL-3356)?\n *\n * A held send is STORED as `scheduled`, parked at a send time no processor\n * run reaches, so the review can put it back on the clock without a second\n * record. Every surface that read the status alone therefore called it\n * \"Scheduled\" and showed a due date in the year 9999 — and offered \"Send\n * now\", \"Reschedule\" and the composer, none of which a merchant may use to\n * release it. This is the one test, so no surface restates it.\n */\nexport function campaignSendHeldForReview(\n send: Pick<CampaignSend, 'status' | 'staffReview'> | null | undefined,\n): boolean {\n const status = String(send?.status ?? '')\n return (\n send?.staffReview?.state === 'held' &&\n (status === 'scheduled' || status === 'draft')\n )\n}\n\n/**\n * What a held send's surfaces tell the merchant: that it is held, why in\n * general terms, that they cannot release it, and what happens next. One\n * sentence set, so the list, the page and the composer agree.\n */\nexport function campaignHeldForReviewNotice(\n send: Pick<CampaignSend, 'staffReview'> | null | undefined,\n): string {\n const reference = send?.staffReview?.reference\n return (\n 'This email is held for review before it sends. New workspaces have ' +\n 'their first emails checked for impersonation of other businesses, and ' +\n 'this one needs a person to look at it. It cannot be released, ' +\n 'rescheduled or edited from here — our team reviews it, and it sends ' +\n 'automatically if it is approved or is canceled if it is not. ' +\n 'Nothing has been sent or counted.' +\n (reference ? ` Quote ${reference} if you contact support.` : '')\n )\n}\n\n/**\n * The batch state the sender writes on an email that is still going out.\n *\n * Absent on every send that finished in one batch, and on every send that\n * predates batching — which is why {@link campaignSendProgress} treats a\n * missing record as \"this is not a batched send\" rather than as zero.\n */\nexport interface CampaignResume {\n /** People the email has resolved and not yet addressed. */\n remaining?: number\n /** Batches it has run. */\n batch?: number\n /** When the next batch may go, ms. Zero when there is not going to be one. */\n nextAtMs?: number\n /** Why it stopped short, when it did. */\n stop?: string\n}\n\n/**\n * WHAT A SEND IS ACTUALLY DOING, for a row that would otherwise lie.\n *\n * An email larger than one send may carry is delivered over several batches,\n * and between them it is stored as `scheduled` — the state the processor\n * claims, and the only one that resumes it without a second index and a\n * second query. Read literally, that is a row saying \"not sent yet\" about an\n * email that has already put five hundred messages in five hundred inboxes.\n *\n * So the stored fields are not the sentence. This is: it takes the status,\n * the delivered count and the batch record, and answers what a person needs\n * to see. Derived at read time and never persisted, exactly as\n * `campaignWindowState` beside it is, because it is a description of stored\n * facts and not a fact of its own.\n *\n * ## The four states, and which stored shape each one is\n *\n * - `pending` — `scheduled`, nothing delivered. A campaign waiting for its\n * time. This is what `scheduled` meant before batching and still does.\n * - `sending` — `scheduled` or `sending` with something delivered and more to\n * come. The state this function exists for.\n * - `sent` — finished, whether in one batch or six.\n * - `stopped` — finished with people it never addressed: canceled mid-flight,\n * failed mid-flight, or stopped by the batch guard. The count is what makes\n * this legible rather than alarming — an email that reached 2,400 of 3,000\n * and stopped is a different conversation from one that reached nobody.\n */\nexport type CampaignSendProgressState =\n | 'pending'\n | 'sending'\n | 'sent'\n | 'stopped'\n\nexport interface CampaignSendProgress {\n state: CampaignSendProgressState\n /** Messages this email has delivered. */\n reached: number\n /**\n * The audience it is working through, when one was recorded. Null when the\n * send never recorded an audience size, which is every send that predates\n * the figure — reported as null rather than as `reached` so a surface does\n * not present a floor as a total.\n */\n audience: number | null\n /** People it has resolved and not yet addressed. */\n remaining: number\n /** Batches it has run. Zero for a send that never batched. */\n batch: number\n /** When the next batch may go, ms. Zero unless {@link state} is `sending`. */\n nextAtMs: number\n /** One line a surface may show verbatim. */\n label: string\n}\n\n/** A stored count as a non-negative integer, or 0. */\nfunction progressCount(raw: unknown): number {\n const value = Math.floor(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\nexport function campaignSendProgress(\n send: CampaignSend | null | undefined,\n): CampaignSendProgress {\n const status = String(send?.status ?? 'sent')\n const reached = progressCount(send?.stats?.sent)\n const rawAudience = send?.stats?.audienceSize\n const audience =\n rawAudience === undefined || rawAudience === null\n ? null\n : progressCount(rawAudience)\n const resume = send?.resume\n const remaining = progressCount(resume?.remaining)\n const batch = progressCount(resume?.batch)\n const nextAtMs = progressCount(resume?.nextAtMs)\n const of = audience !== null ? ` of ${audience.toLocaleString()}` : ''\n\n // Still going: more to address, and a run that will address it. A campaign\n // a merchant CANCELED is not still going however much is left, which is why\n // the status is read before the remainder.\n if (\n remaining > 0 &&\n nextAtMs > 0 &&\n (status === 'scheduled' || status === 'sending')\n ) {\n return {\n state: 'sending',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs,\n label: `Sending — reached ${reached.toLocaleString()}${of}`,\n }\n }\n // Waiting for its time, with nothing delivered. The pre-batching meaning of\n // `scheduled`, and still the common one.\n if ((status === 'scheduled' || status === 'sending') && reached === 0) {\n return {\n state: 'pending',\n reached: 0,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label: status === 'sending' ? 'Sending' : 'Scheduled',\n }\n }\n if (remaining > 0) {\n const why =\n status === 'canceled'\n ? 'canceled'\n : status === 'failed'\n ? 'stopped by an error'\n : 'stopped'\n return {\n state: 'stopped',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label:\n `Reached ${reached.toLocaleString()}${of} — ${why} with ` +\n `${remaining.toLocaleString()} not addressed`,\n }\n }\n return {\n state: 'sent',\n reached,\n audience,\n remaining: 0,\n batch,\n nextAtMs: 0,\n label:\n batch > 1\n ? `Sent to ${reached.toLocaleString()}${of} over ${batch} runs`\n : `Sent to ${reached.toLocaleString()}${of}`,\n }\n}\n\n/**\n * What a ROW says about one email, in one word and one line.\n *\n * {@link campaignSendProgress} answers what a send is DOING, and a draft is\n * not doing anything: it has no status the progress states cover, and — since\n * an absent status reads as `sent` and a draft has no counters — asking it\n * about one answers \"Sent to 0\", which is the worst available sentence about\n * an email nobody has written yet. So the draft is settled here and\n * everything else is deferred, unchanged, to the derivation that owns it.\n *\n * One helper rather than the same two-line branch on four surfaces. The\n * campaigns table, the emails list, an email's own page and a campaign's\n * emails table all draw this, and four copies is how three of them come to\n * say \"Scheduled\" about a campaign that has delivered five hundred messages.\n */\nexport type CampaignSendDisplayState = 'draft' | 'held' | CampaignSendProgressState\n\nexport interface CampaignSendDisplay {\n state: CampaignSendDisplayState\n /** One line a surface may show verbatim. */\n label: string\n /** The progress underneath, for a surface that wants the figures. */\n progress: CampaignSendProgress\n}\n\nexport function campaignSendDisplay(\n send: CampaignSend | null | undefined,\n): CampaignSendDisplay {\n const progress = campaignSendProgress(send)\n // Before the draft test: a held send may be stored as either (AGL-3356).\n if (campaignSendHeldForReview(send)) {\n return { state: 'held', label: 'Held for review', progress }\n }\n if (String(send?.status ?? '') === 'draft') {\n return { state: 'draft', label: 'Draft', progress }\n }\n return { state: progress.state, label: progress.label, progress }\n}\n\n/**\n * Whether this email is between batches, with more of its audience to reach.\n *\n * The one question two controls on an email's page turn on. It is stored as\n * `scheduled` — the state the processor claims — so a surface reading the\n * status alone offers \"Send now\" on a campaign that is already going out, and\n * `sendNow` re-resolves the WHOLE audience rather than continuing: everyone\n * already reached would receive a second copy under the same `cid`.\n */\nexport function campaignSendIsMidFlight(\n send: CampaignSend | null | undefined,\n): boolean {\n return campaignSendProgress(send).state === 'sending'\n}\n\n/**\n * The lists ONE SEND addressed — which can be narrower than the lists its\n * campaign is aimed at.\n *\n * A campaign holds the lists a merchant plans to reach; each send inside it\n * picks one audience, and that audience may be a segment or the site's leads\n * rather than any of them. Answering from the send is therefore the only\n * honest answer for a send's own detail page.\n *\n * An array for a document that stores one id, deliberately: the question\n * \"which lists did this reach\" has a plural answer everywhere it is asked,\n * and a caller that unwraps a single id today is a caller to revisit if a\n * send ever addresses two.\n */\nexport function campaignSendListIds(send: CampaignSend): string[] {\n return send.audience === 'list' && send.listId ? [send.listId] : []\n}\n\n/**\n * A number summed across sends, with how much of the campaign it covers.\n *\n * `value` is `null` when NO send recorded the field — which is not zero, for\n * the reason `campaign-report.ts` gives at length: an unrecorded delivery\n * count and a delivery count of zero lead a merchant to opposite conclusions\n * about their sending domain.\n *\n * `recorded` below `sends` means the total is a floor. A campaign whose\n * older sends predate the delivery webhook has a real number that describes\n * part of itself, and saying which part is the difference between a total and\n * a guess.\n */\nexport interface CampaignAggregate {\n value: number | null\n /** Sends that recorded this field. */\n recorded: number\n /** Sends in the campaign. */\n sends: number\n}\n\n/** Every rolled-up figure for one campaign. */\nexport interface CampaignRollup {\n /** Sends that have actually gone out. */\n sends: number\n /** Sends still waiting for their send time, having delivered nothing. */\n scheduled: number\n /**\n * Sends part way through an audience larger than one batch.\n *\n * Counted apart from `scheduled` and from `sends`, because it is neither:\n * mail has gone out, and more is going to. Both of those are facts a\n * merchant reading a campaign row needs, and the stored status carries\n * only the first.\n */\n sending: number\n /**\n * Emails that have been created and not yet written or sent.\n *\n * Counted apart from `sends` and from `scheduled`, because a draft is\n * neither: it has mailed nobody, and it is not on the clock to.\n */\n drafts: number\n addressed: CampaignAggregate\n sent: CampaignAggregate\n delivered: CampaignAggregate\n opens: CampaignAggregate\n uniqueOpens: CampaignAggregate\n clicks: CampaignAggregate\n uniqueClicks: CampaignAggregate\n bounced: CampaignAggregate\n complained: CampaignAggregate\n unsubscribes: CampaignAggregate\n /** Distinct openers over delivered, across every send that recorded both. */\n openRate: SendRate | null\n /** Distinct clickers over delivered. */\n clickRate: SendRate | null\n /** Unsubscribes over delivered. */\n unsubscribeRate: SendRate | null\n /** The most recent send time in the campaign, for ordering a list. */\n lastSentAtMs: number | null\n}\n\nconst aggregate = (\n sends: CampaignSend[],\n read: (stats: SendStats) => number | undefined,\n): CampaignAggregate => {\n let total = 0\n let recorded = 0\n for (const send of sends) {\n const value = read(send.stats ?? {})\n // `undefined` is \"this send never recorded it\"; a recorded 0 counts as\n // measured, and moves `recorded` without moving the total.\n if (value === undefined || value === null) continue\n const numeric = Number(value)\n if (!Number.isFinite(numeric)) continue\n total += numeric\n recorded += 1\n }\n return { value: recorded ? total : null, recorded, sends: sends.length }\n}\n\n/** When a send happened, in epoch milliseconds, or null. */\nexport function campaignSendAtMs(send: CampaignSend): number | null {\n const seconds = send.sentAt?.seconds\n if (typeof seconds === 'number') return seconds * 1000\n if (typeof send.sendAtMs === 'number') return send.sendAtMs\n return null\n}\n\n/**\n * Rolls a campaign's sends into one set of figures.\n *\n * Rates are taken over the sends that recorded BOTH sides, so a campaign\n * whose first send predates the delivery webhook reports the open rate of the\n * sends that can be measured rather than one deflated by a send with no\n * denominator.\n */\nexport function campaignRollup(sends: CampaignSend[]): CampaignRollup {\n /*==========================================\n * ONLY MAIL THAT HAS GONE OUT IS A SEND.\n *\n * An email exists from the moment it is created, so this collection now\n * holds records in three states that have mailed nobody — `draft`,\n * `scheduled` and the `sending` claim — and every figure below is about\n * mail that was delivered.\n *\n * Two separate faults if they are left in. The COUNT would report a\n * campaign as having sent three emails when it has sent two and is still\n * writing the third. And every aggregate carries `sends` as the denominator\n * of its own \"recorded by N of M\" label, so an unsent record would enlarge\n * the M — publishing a coverage figure that says the campaign is missing\n * data it was never going to have.\n *=========================================*/\n /*\n * A SEND THAT IS STILL GOING HAS STILL GONE.\n *\n * `emailIsUnsent` reads the stored status, and an email delivering an\n * audience larger than one batch is stored as `scheduled` between runs —\n * so reading it literally drops a send that has put five hundred messages\n * in five hundred inboxes out of every total on the campaign, and counts it\n * under \"scheduled\" as though nothing had happened. `campaignSendProgress`\n * is what tells \"waiting for its time\" from \"part way through\", and only\n * the first is genuinely unsent.\n *\n * A DRAFT is settled by the status because the progress states do not cover\n * it — see `campaignSendDisplay`.\n */\n const notYet = (send: CampaignSend): boolean =>\n String(send.status ?? '') === 'draft' ||\n campaignSendProgress(send).state === 'pending'\n const gone = sends.filter((send) => !notYet(send))\n const measurable = sends.filter(\n (send) => send.stats?.delivered !== undefined,\n )\n const deliveredTotal = aggregate(measurable, (stats) => stats.delivered)\n return {\n sends: gone.filter((send) => send.status !== 'canceled').length,\n // Waiting for its time with nothing delivered — which is what\n // \"scheduled\" meant before an email could be delivered over several runs,\n // and is now narrower than the stored status of that name.\n scheduled: sends.filter(\n (send) => campaignSendProgress(send).state === 'pending',\n ).length,\n sending: sends.filter(\n (send) => campaignSendProgress(send).state === 'sending',\n ).length,\n drafts: sends.filter((send) => send.status === 'draft').length,\n addressed: aggregate(gone, (stats) => stats.recipients),\n sent: aggregate(gone, (stats) => stats.sent),\n delivered: aggregate(gone, (stats) => stats.delivered),\n opens: aggregate(gone, (stats) => stats.opens),\n uniqueOpens: aggregate(gone, (stats) => stats.uniqueOpens),\n clicks: aggregate(gone, (stats) => stats.clicks),\n uniqueClicks: aggregate(gone, (stats) => stats.uniqueClicks),\n bounced: aggregate(gone, (stats) => stats.bounced),\n complained: aggregate(gone, (stats) => stats.complained),\n unsubscribes: aggregate(gone, (stats) => stats.unsubscribes),\n openRate: sendRate(\n aggregate(measurable, (stats) => stats.uniqueOpens).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n clickRate: sendRate(\n aggregate(measurable, (stats) => stats.uniqueClicks).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n unsubscribeRate: sendRate(\n aggregate(measurable, (stats) => stats.unsubscribes).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n lastSentAtMs: sends.reduce<number | null>((latest, send) => {\n const at = campaignSendAtMs(send)\n return at !== null && (latest === null || at > latest) ? at : latest\n }, null),\n }\n}\n\n/**\n * Where a campaign stands against its own window.\n *\n * Derived at read time and never persisted — the `status` values on a SEND\n * (`sent`, `scheduled`, `canceled`, `failed`) are stored strings that a\n * processor branches on, and a display state sharing those spellings would\n * eventually be written back.\n */\nexport type CampaignWindowState = 'undated' | 'upcoming' | 'running' | 'ended'\n\nexport function campaignWindowState(\n campaign: Pick<EmailCampaign, 'startAtMs' | 'endAtMs'>,\n nowMs: number,\n): CampaignWindowState {\n const start = campaign.startAtMs ?? null\n const end = campaign.endAtMs ?? null\n if (start === null && end === null) return 'undated'\n if (start !== null && nowMs < start) return 'upcoming'\n if (end !== null && nowMs > end) return 'ended'\n return 'running'\n}\n\n/** One row of the campaigns table. */\nexport interface CampaignListRow {\n /** The id the detail route resolves — a container id, or a send id. */\n id: string\n name: string\n /**\n * True when this row IS a send with no container.\n *\n * A campaign sent before containers existed is shown as a campaign of one\n * rather than dropped from the list, and the detail route falls back to the\n * send's own report for it. Nothing about that send is rewritten.\n */\n legacy: boolean\n startAtMs: number | null\n endAtMs: number | null\n listIds: string[]\n sends: CampaignSend[]\n rollup: CampaignRollup\n windowState: CampaignWindowState\n /** For ordering: the campaign's start, else its most recent send. */\n atMs: number | null\n}\n\n/**\n * The campaigns table's rows: every container, plus every send that belongs\n * to none.\n *\n * The second half is what makes the container additive. A merchant's history\n * is the sends they already have; a list that showed only containers would\n * read as an empty product on the day this shipped, and a backfill that\n * adopted each old send into a container of one would rewrite documents whose\n * ids are cited by mail already delivered. Adopting them AT READ TIME costs a\n * pass over a list already in memory and rewrites nothing.\n *\n * Newest first, on the start date where there is one and the last send\n * otherwise. Rows with no date at all sort last: they are campaigns nobody\n * has scheduled or sent, and dating them from nothing would be an invention.\n */\nexport function campaignListRows(\n campaigns: EmailCampaign[],\n sends: CampaignSend[],\n nowMs: number,\n): CampaignListRow[] {\n const byCampaign = new Map<string, CampaignSend[]>()\n const orphans: CampaignSend[] = []\n for (const send of sends) {\n const containerId = send.emailCampaignId\n if (!containerId) {\n orphans.push(send)\n continue\n }\n const existing = byCampaign.get(containerId)\n if (existing) existing.push(send)\n else byCampaign.set(containerId, [send])\n }\n\n const rows: CampaignListRow[] = campaigns.map((campaign) => {\n const own = byCampaign.get(campaign.$id) ?? []\n const rollup = campaignRollup(own)\n return {\n id: campaign.$id,\n name: campaign.name || 'Untitled campaign',\n legacy: false,\n startAtMs: campaign.startAtMs ?? null,\n endAtMs: campaign.endAtMs ?? null,\n listIds: campaign.listIds ?? [],\n sends: own,\n rollup,\n windowState: campaignWindowState(campaign, nowMs),\n atMs: campaign.startAtMs ?? rollup.lastSentAtMs ?? null,\n }\n })\n\n for (const send of orphans) {\n const rollup = campaignRollup([send])\n const at = campaignSendAtMs(send)\n rows.push({\n id: send.$id,\n name: send.subject || 'Untitled campaign',\n legacy: true,\n startAtMs: at,\n endAtMs: at,\n listIds: [],\n sends: [send],\n rollup,\n // A send has no window of its own; it happened, or it is going to.\n windowState:\n send.status === 'scheduled'\n ? 'upcoming'\n : at === null\n ? 'undated'\n : 'ended',\n atMs: at,\n })\n }\n\n return rows.sort((a, b) => {\n if (a.atMs === null && b.atMs === null) return a.name.localeCompare(b.name)\n if (a.atMs === null) return 1\n if (b.atMs === null) return -1\n return b.atMs - a.atMs\n })\n}\n"],"names":["readListUnsubscribeSetting","sendRate","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","EMAIL_CAMPAIGNS_COLLECTION","CAMPAIGN_SENDS_COLLECTION","orgEmailCampaignsPath","orgId","orgCampaignSendsPath","orgCampaignSequenceReportsPath","CAMPAIGN_SEND_HOST_FIELD","CAMPAIGN_ORG_SCOPE","campaignHostScope","hostId","campaignSendVisibleTo","campaignSiteIds","campaign","tokens","visibleTo","includes","ids","token","startsWith","length","push","slice","campaignPlacedOnHost","campaignVisibleTo","siteIds","unique","Set","filter","id","map","CAMPAIGN_SEND_CONTAINER_FIELD","campaignListUnsubscribe","listUnsubscribe","campaignForcedListUnsubscribeSends","sends","send","forced","campaignSendHeldForReview","status","String","staffReview","state","campaignHeldForReviewNotice","reference","progressCount","raw","value","Math","floor","Number","isFinite","campaignSendProgress","reached","stats","sent","rawAudience","audienceSize","audience","undefined","resume","remaining","batch","nextAtMs","of","toLocaleString","label","why","campaignSendDisplay","progress","campaignSendIsMidFlight","campaignSendListIds","listId","aggregate","read","total","recorded","numeric","campaignSendAtMs","seconds","sentAt","sendAtMs","campaignRollup","deliveredTotal","notYet","gone","measurable","delivered","scheduled","sending","drafts","addressed","recipients","opens","uniqueOpens","clicks","uniqueClicks","bounced","complained","unsubscribes","openRate","clickRate","unsubscribeRate","lastSentAtMs","reduce","latest","at","campaignWindowState","nowMs","start","startAtMs","end","endAtMs","campaignListRows","campaigns","byCampaign","Map","orphans","containerId","emailCampaignId","existing","get","set","rows","own","$id","rollup","name","legacy","listIds","windowState","atMs","subject","sort","a","b","localeCompare"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-container.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 CAMPAIGN IS A CONTAINER; A SEND IS ONE MESSAGE INSIDE IT.\n *\n * ## Both belong to the ORGANIZATION (AGL-3273)\n *\n * `orgs/{orgId}/emailCampaigns/{campaignId}` holds a CONTAINER: a name, a\n * window, the lists it aims at. `orgs/{orgId}/campaigns/{sendId}` holds a\n * SEND: one subject, one body, one audience, one set of counters.\n *\n * They lived under one site (`hosts/{hostId}/…`) until the audience side —\n * contacts, lists, topics, sending domains — had long since become the\n * org's. An organization's outbound sequences then had to be filed under a\n * campaign that belonged to its marketing site, and the org-level Leads page\n * could not name a campaign at all. Moving both up is what lets one campaign\n * coordinate a push across every site, a sequence and the leads it produced.\n *\n * ## What stays a SITE fact, and where it is recorded\n *\n * A campaign's reach is {@link EmailCampaign.visibleTo}, the same scope\n * tokens every org-owned resource carries: `['org']` is every site, and\n * `['host:{id}', …]` is the sites it is placed on. It decides which sites'\n * pickers offer the campaign and which scoped collaborators can read it —\n * nothing else, because membership stays a field on the member.\n *\n * A send, by contrast, is always sent AS one site: that site's sender, the\n * consent the person gave that site, the site's unsubscribe signature and\n * its designs. {@link CAMPAIGN_SEND_HOST_FIELD} records which, and the\n * send's `visibleTo` is that one site's token.\n *\n * ## Ids did not change, and that is load-bearing\n *\n * Every unsubscribe link that has ever gone out carries `hostId` and\n * `cid={sendId}`, both inside the link's HMAC, and those emails sit in\n * inboxes forever. So the move copied every document under its existing id,\n * and every reader that starts from a link resolves the send through the\n * site's org (`resolveCampaignSendRef` in the Marketing plugin), with the\n * old site path as the fallback for a document the migration has not reached.\n * A member's `campaignIds` names container ids, which likewise never moved.\n *\n * A send written before containers existed carries no\n * {@link CAMPAIGN_SEND_CONTAINER_FIELD}, and {@link campaignListRows}\n * presents it as a container of one rather than hiding it.\n *\n * ## Why the arithmetic is here\n *\n * The same reason `campaign-report.ts` gives for the per-send rates: a\n * denominator chosen in JSX is a denominator nobody tests. Aggregating across\n * sends adds one problem the single-send report does not have — some sends\n * recorded a field and others never did — and summing those into one number\n * silently reports a partial total as a complete one. Every aggregate here\n * therefore reports how many sends it could measure.\n */\n\nimport {\n readListUnsubscribeSetting,\n type ListUnsubscribeForcedReason,\n} from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n sendRate,\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { CAMPAIGN_SEQUENCE_REPORTS_COLLECTION } from './campaign-report'\n\n/** The org collection holding campaign CONTAINERS. */\nexport const EMAIL_CAMPAIGNS_COLLECTION = 'emailCampaigns'\n\n/** The org collection holding SENDS — one document per email. */\nexport const CAMPAIGN_SENDS_COLLECTION = 'campaigns'\n\n/** `orgs/{orgId}/emailCampaigns`, as path segments. */\nexport function orgEmailCampaignsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, EMAIL_CAMPAIGNS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaigns`, as path segments. */\nexport function orgCampaignSendsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SENDS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaignSequenceReports`, as path segments. */\nexport function orgCampaignSequenceReportsPath(\n orgId: string,\n): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION]\n}\n\n/**\n * The field on a SEND naming the site it is sent AS.\n *\n * Required on every send written since AGL-3273. The sender, the consent\n * group, the unsubscribe signature, the designs and the experiments all\n * belong to that site, so a send with no site cannot be delivered.\n */\nexport const CAMPAIGN_SEND_HOST_FIELD = 'hostId'\n\n/** The scope token naming every site in the org, as `scope-tokens.ts` spells it. */\nexport const CAMPAIGN_ORG_SCOPE = 'org'\n\n/** `host:{hostId}`, the scope token naming one site. */\nexport function campaignHostScope(hostId: string): string {\n return `host:${hostId}`\n}\n\n/**\n * The `visibleTo` a send is stamped with: the one site it sends as.\n *\n * Not `['org']`. An org-wide member reads every send regardless, and a\n * collaborator invited to a sibling site has no business reading who this\n * site mailed.\n */\nexport function campaignSendVisibleTo(hostId: string): string[] {\n return [campaignHostScope(hostId)]\n}\n\n/**\n * The sites a container is placed on, or `null` when it is on every site.\n *\n * An absent or empty `visibleTo` reads as NO sites, for the reason\n * `visibleToHost` in `scope-tokens.ts` gives: a document nobody scoped is\n * not the same fact as one scoped to everyone, and hiding is the\n * recoverable direction.\n */\nexport function campaignSiteIds(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n): string[] | null {\n const tokens = campaign?.visibleTo ?? []\n if (tokens.includes(CAMPAIGN_ORG_SCOPE)) return null\n const ids: string[] = []\n for (const token of tokens) {\n if (typeof token === 'string' && token.startsWith('host:') && token.length > 5) {\n ids.push(token.slice(5))\n }\n }\n return ids\n}\n\n/** Whether a container is offered on this site. */\nexport function campaignPlacedOnHost(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n hostId: string,\n): boolean {\n const ids = campaignSiteIds(campaign)\n return ids === null || ids.includes(hostId)\n}\n\n/**\n * The `visibleTo` a container is stamped with, from a site selection.\n *\n * `null` (or an empty selection) is every site. A selection is deduped and\n * capped at thirty, the ceiling `array-contains-any` and the rules' `hasAny`\n * both impose.\n */\nexport function campaignVisibleTo(siteIds: readonly string[] | null): string[] {\n if (!siteIds || siteIds.length === 0) return [CAMPAIGN_ORG_SCOPE]\n const unique = [...new Set(siteIds.filter((id) => typeof id === 'string' && id))]\n return unique.slice(0, 30).map(campaignHostScope)\n}\n\n/**\n * The field on a SEND naming the campaign it belongs to.\n *\n * Not `campaignId`: on a send document that name already means the send's own\n * id — it is what `cid` carries and what the report route addresses — and one\n * word meaning both would be read into the other on the first edit.\n */\nexport const CAMPAIGN_SEND_CONTAINER_FIELD = 'emailCampaignId'\n\n/**\n * A campaign: the container, not a message.\n *\n * Stored at `orgs/{orgId}/emailCampaigns/{campaignId}`.\n */\nexport interface EmailCampaign {\n $id: string\n /** What the merchant called it. */\n name: string\n /** When the campaign window opens. Null for a campaign with no dates. */\n startAtMs?: number | null\n /** When it closes. Null for an open-ended campaign. */\n endAtMs?: number | null\n /** Org email lists this campaign is aimed at, by list id. */\n listIds?: string[]\n /**\n * The stream this campaign's emails open on.\n *\n * A DEFAULT, not a constraint. The topic decides who a send skips, what the\n * preference page linked from the footer highlights, and which stream a\n * resulting opt-out is recorded against — all facts about one MESSAGE, and\n * one campaign may legitimately carry a newsletter and a promotion. So the\n * composer's picker is what the send records; this is what the picker opens\n * on, which is what stops a \"Sales\" campaign quietly mailing under\n * `marketing`.\n */\n topicId?: string\n /**\n * The sites the campaign is placed on, as scope tokens: `['org']` for\n * every site, `['host:{id}', …]` for a set. See {@link campaignSiteIds}.\n */\n visibleTo?: string[]\n /**\n * Whether its emails carry `List-Unsubscribe` and `List-Unsubscribe-Post`\n * (RFC 8058), the header mail clients draw their own Unsubscribe button\n * from (AGL-3307). ON unless stored `false` — absent on every campaign\n * written before the setting existed, which read as on because that is\n * what they always did. Read it through {@link campaignListUnsubscribe}.\n *\n * Off is overridden per send by the bulk guard; see\n * {@link CampaignListUnsubscribeRecord}. The footer's unsubscribe link is\n * in every email either way.\n */\n listUnsubscribe?: boolean\n /**\n * The `utm_campaign` labels that mean this campaign (AGL-3461), stored as\n * {@link campaignUtmLabels} normalizes them. A visitor who arrives on a\n * link carrying one is credited to this campaign, as a visitor who reads a\n * page filed under it is. Read it through {@link campaignUtmLabels}.\n */\n utmCampaigns?: string[]\n createdAtMs?: number\n createdBy?: string\n deletedAt?: unknown\n}\n\n/**\n * How many `utm_campaign` labels one campaign may answer to. A ceiling on the\n * field, so an `array-contains` lookup stays one cheap index entry per label.\n */\nexport const CAMPAIGN_UTM_LABELS_MAX = 20\n\n/**\n * One `utm_campaign` label as it is stored and looked up: trimmed, lowercase,\n * at most 100 characters (the length the label parser keeps).\n *\n * Lowercase because the same push is routinely tagged `OneJob-AI` in one ad\n * and `onejob-ai` in the next, and a match that told them apart would leave\n * half a campaign's arrivals on a label nobody looks at. Empty for a value\n * that is not a label at all.\n */\nexport function normalizeCampaignUtmLabel(raw: unknown): string {\n if (typeof raw !== 'string') return ''\n const label = raw.trim().toLowerCase().slice(0, 100)\n // An address is never a campaign label — the label parser refuses one too.\n if (!label || /[^\\s@]+@[^\\s@]+\\.[^\\s@]+/.test(label)) return ''\n return label\n}\n\n/**\n * A campaign's `utm_campaign` labels as a clean list: normalized, deduped,\n * capped. Accepts the stored array or a comma- or newline-separated string,\n * which is how the edit drawer collects them.\n */\nexport function campaignUtmLabels(raw: unknown): string[] {\n const parts = Array.isArray(raw)\n ? raw\n : typeof raw === 'string'\n ? raw.split(/[\\n,]/)\n : []\n const labels: string[] = []\n for (const part of parts) {\n const label = normalizeCampaignUtmLabel(part)\n if (!label || labels.includes(label)) continue\n labels.push(label)\n if (labels.length >= CAMPAIGN_UTM_LABELS_MAX) break\n }\n return labels\n}\n\n/**\n * Whether a campaign's emails carry the mail-client unsubscribe button:\n * the shared reader sequences use, with a campaign's default of ON.\n */\nexport function campaignListUnsubscribe(\n campaign: Pick<EmailCampaign, 'listUnsubscribe'> | null | undefined,\n): boolean {\n return readListUnsubscribeSetting(campaign?.listUnsubscribe, true)\n}\n\n/**\n * What one SEND did with the List-Unsubscribe header, written by the\n * campaign sender on every batch (AGL-3307).\n */\nexport interface CampaignListUnsubscribeRecord {\n /** The campaign's setting when it went out. */\n requested?: boolean\n /** Whether the header was on the messages. */\n on?: boolean\n /** True when the setting said off and the bulk guard turned it back on. */\n forced?: boolean\n reason?: ListUnsubscribeForcedReason\n /** One sentence saying why, for the campaign page. */\n detail?: string\n /** Campaign messages in the day, this email included, it was judged on. */\n volume?: number\n /** The threshold it was judged against. */\n threshold?: number\n atMs?: number\n}\n\n/** The sends among these whose header the bulk guard forced back on. */\nexport function campaignForcedListUnsubscribeSends<\n T extends Pick<CampaignSend, 'listUnsubscribe'>,\n>(sends: readonly T[] | null | undefined): T[] {\n return (sends ?? []).filter((send) => send?.listUnsubscribe?.forced === true)\n}\n\n/** One send, as much of it as a list or a rollup needs. */\nexport interface CampaignSend {\n $id: string\n subject?: string\n /** The audience KIND: `'leads'`, `'members'`, `'segment'`, `'list'`. */\n audience?: string\n /** The list this send addressed, when the kind is `'list'`. */\n listId?: string\n /** The segment this send addressed, when the kind is `'segment'`. */\n segmentId?: string\n /** Which container it belongs to, absent on a send written before them. */\n emailCampaignId?: string\n /** The site it is sent as. See {@link CAMPAIGN_SEND_HOST_FIELD}. */\n hostId?: string\n status?: string\n sentAt?: { seconds?: number } | null\n sendAtMs?: number\n /**\n * When the record was minted, stamped by every writer that creates one.\n *\n * Absent on a send written before the stamp existed. The lists that draw\n * drafts beside sends order on it through `emailListTimeMs`, which is why\n * it is here rather than only on the loose record shape: a draft carries\n * neither `sentAt` nor `sendAtMs`, so this is the only time it has.\n */\n createdAtMs?: number\n stats?: SendStats\n /** How far a send that goes out over several batches has got. */\n resume?: CampaignResume\n /** What it did with the List-Unsubscribe header. Absent before AGL-3307. */\n listUnsubscribe?: CampaignListUnsubscribeRecord\n /**\n * Staff review of a send the outbound phishing screen held (AGL-3356).\n * Written by the send core, decided only by staff. See\n * {@link campaignSendHeldForReview}.\n */\n staffReview?: CampaignStaffReview\n}\n\n/** A held send's review, as the send core and the staff decision write it. */\nexport interface CampaignStaffReview {\n /** `held` until staff decide; then `released` or `rejected`. */\n state?: string\n /** `HS-…`, the reference the merchant quotes to support. */\n reference?: string\n reviewId?: string\n heldAtMs?: number\n}\n\n/**\n * Is this send waiting on a staff review (AGL-3356)?\n *\n * A held send is STORED as `scheduled`, parked at a send time no processor\n * run reaches, so the review can put it back on the clock without a second\n * record. Every surface that read the status alone therefore called it\n * \"Scheduled\" and showed a due date in the year 9999 — and offered \"Send\n * now\", \"Reschedule\" and the composer, none of which a merchant may use to\n * release it. This is the one test, so no surface restates it.\n */\nexport function campaignSendHeldForReview(\n send: Pick<CampaignSend, 'status' | 'staffReview'> | null | undefined,\n): boolean {\n const status = String(send?.status ?? '')\n return (\n send?.staffReview?.state === 'held' &&\n (status === 'scheduled' || status === 'draft')\n )\n}\n\n/**\n * What a held send's surfaces tell the merchant: that it is held, why in\n * general terms, that they cannot release it, and what happens next. One\n * sentence set, so the list, the page and the composer agree.\n */\nexport function campaignHeldForReviewNotice(\n send: Pick<CampaignSend, 'staffReview'> | null | undefined,\n): string {\n const reference = send?.staffReview?.reference\n return (\n 'This email is held for review before it sends. New workspaces have ' +\n 'their first emails checked for impersonation of other businesses, and ' +\n 'this one needs a person to look at it. It cannot be released, ' +\n 'rescheduled or edited from here — our team reviews it, and it sends ' +\n 'automatically if it is approved or is canceled if it is not. ' +\n 'Nothing has been sent or counted.' +\n (reference ? ` Quote ${reference} if you contact support.` : '')\n )\n}\n\n/**\n * The batch state the sender writes on an email that is still going out.\n *\n * Absent on every send that finished in one batch, and on every send that\n * predates batching — which is why {@link campaignSendProgress} treats a\n * missing record as \"this is not a batched send\" rather than as zero.\n */\nexport interface CampaignResume {\n /** People the email has resolved and not yet addressed. */\n remaining?: number\n /** Batches it has run. */\n batch?: number\n /** When the next batch may go, ms. Zero when there is not going to be one. */\n nextAtMs?: number\n /** Why it stopped short, when it did. */\n stop?: string\n}\n\n/**\n * WHAT A SEND IS ACTUALLY DOING, for a row that would otherwise lie.\n *\n * An email larger than one send may carry is delivered over several batches,\n * and between them it is stored as `scheduled` — the state the processor\n * claims, and the only one that resumes it without a second index and a\n * second query. Read literally, that is a row saying \"not sent yet\" about an\n * email that has already put five hundred messages in five hundred inboxes.\n *\n * So the stored fields are not the sentence. This is: it takes the status,\n * the delivered count and the batch record, and answers what a person needs\n * to see. Derived at read time and never persisted, exactly as\n * `campaignWindowState` beside it is, because it is a description of stored\n * facts and not a fact of its own.\n *\n * ## The four states, and which stored shape each one is\n *\n * - `pending` — `scheduled`, nothing delivered. A campaign waiting for its\n * time. This is what `scheduled` meant before batching and still does.\n * - `sending` — `scheduled` or `sending` with something delivered and more to\n * come. The state this function exists for.\n * - `sent` — finished, whether in one batch or six.\n * - `stopped` — finished with people it never addressed: canceled mid-flight,\n * failed mid-flight, or stopped by the batch guard. The count is what makes\n * this legible rather than alarming — an email that reached 2,400 of 3,000\n * and stopped is a different conversation from one that reached nobody.\n */\nexport type CampaignSendProgressState =\n | 'pending'\n | 'sending'\n | 'sent'\n | 'stopped'\n\nexport interface CampaignSendProgress {\n state: CampaignSendProgressState\n /** Messages this email has delivered. */\n reached: number\n /**\n * The audience it is working through, when one was recorded. Null when the\n * send never recorded an audience size, which is every send that predates\n * the figure — reported as null rather than as `reached` so a surface does\n * not present a floor as a total.\n */\n audience: number | null\n /** People it has resolved and not yet addressed. */\n remaining: number\n /** Batches it has run. Zero for a send that never batched. */\n batch: number\n /** When the next batch may go, ms. Zero unless {@link state} is `sending`. */\n nextAtMs: number\n /** One line a surface may show verbatim. */\n label: string\n}\n\n/** A stored count as a non-negative integer, or 0. */\nfunction progressCount(raw: unknown): number {\n const value = Math.floor(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\nexport function campaignSendProgress(\n send: CampaignSend | null | undefined,\n): CampaignSendProgress {\n const status = String(send?.status ?? 'sent')\n const reached = progressCount(send?.stats?.sent)\n const rawAudience = send?.stats?.audienceSize\n const audience =\n rawAudience === undefined || rawAudience === null\n ? null\n : progressCount(rawAudience)\n const resume = send?.resume\n const remaining = progressCount(resume?.remaining)\n const batch = progressCount(resume?.batch)\n const nextAtMs = progressCount(resume?.nextAtMs)\n const of = audience !== null ? ` of ${audience.toLocaleString()}` : ''\n\n // Still going: more to address, and a run that will address it. A campaign\n // a merchant CANCELED is not still going however much is left, which is why\n // the status is read before the remainder.\n if (\n remaining > 0 &&\n nextAtMs > 0 &&\n (status === 'scheduled' || status === 'sending')\n ) {\n return {\n state: 'sending',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs,\n label: `Sending — reached ${reached.toLocaleString()}${of}`,\n }\n }\n // Waiting for its time, with nothing delivered. The pre-batching meaning of\n // `scheduled`, and still the common one.\n if ((status === 'scheduled' || status === 'sending') && reached === 0) {\n return {\n state: 'pending',\n reached: 0,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label: status === 'sending' ? 'Sending' : 'Scheduled',\n }\n }\n if (remaining > 0) {\n const why =\n status === 'canceled'\n ? 'canceled'\n : status === 'failed'\n ? 'stopped by an error'\n : 'stopped'\n return {\n state: 'stopped',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label:\n `Reached ${reached.toLocaleString()}${of} — ${why} with ` +\n `${remaining.toLocaleString()} not addressed`,\n }\n }\n return {\n state: 'sent',\n reached,\n audience,\n remaining: 0,\n batch,\n nextAtMs: 0,\n label:\n batch > 1\n ? `Sent to ${reached.toLocaleString()}${of} over ${batch} runs`\n : `Sent to ${reached.toLocaleString()}${of}`,\n }\n}\n\n/**\n * What a ROW says about one email, in one word and one line.\n *\n * {@link campaignSendProgress} answers what a send is DOING, and a draft is\n * not doing anything: it has no status the progress states cover, and — since\n * an absent status reads as `sent` and a draft has no counters — asking it\n * about one answers \"Sent to 0\", which is the worst available sentence about\n * an email nobody has written yet. So the draft is settled here and\n * everything else is deferred, unchanged, to the derivation that owns it.\n *\n * One helper rather than the same two-line branch on four surfaces. The\n * campaigns table, the emails list, an email's own page and a campaign's\n * emails table all draw this, and four copies is how three of them come to\n * say \"Scheduled\" about a campaign that has delivered five hundred messages.\n */\nexport type CampaignSendDisplayState = 'draft' | 'held' | CampaignSendProgressState\n\nexport interface CampaignSendDisplay {\n state: CampaignSendDisplayState\n /** One line a surface may show verbatim. */\n label: string\n /** The progress underneath, for a surface that wants the figures. */\n progress: CampaignSendProgress\n}\n\nexport function campaignSendDisplay(\n send: CampaignSend | null | undefined,\n): CampaignSendDisplay {\n const progress = campaignSendProgress(send)\n // Before the draft test: a held send may be stored as either (AGL-3356).\n if (campaignSendHeldForReview(send)) {\n return { state: 'held', label: 'Held for review', progress }\n }\n if (String(send?.status ?? '') === 'draft') {\n return { state: 'draft', label: 'Draft', progress }\n }\n return { state: progress.state, label: progress.label, progress }\n}\n\n/**\n * Whether this email is between batches, with more of its audience to reach.\n *\n * The one question two controls on an email's page turn on. It is stored as\n * `scheduled` — the state the processor claims — so a surface reading the\n * status alone offers \"Send now\" on a campaign that is already going out, and\n * `sendNow` re-resolves the WHOLE audience rather than continuing: everyone\n * already reached would receive a second copy under the same `cid`.\n */\nexport function campaignSendIsMidFlight(\n send: CampaignSend | null | undefined,\n): boolean {\n return campaignSendProgress(send).state === 'sending'\n}\n\n/**\n * The lists ONE SEND addressed — which can be narrower than the lists its\n * campaign is aimed at.\n *\n * A campaign holds the lists a merchant plans to reach; each send inside it\n * picks one audience, and that audience may be a segment or the site's leads\n * rather than any of them. Answering from the send is therefore the only\n * honest answer for a send's own detail page.\n *\n * An array for a document that stores one id, deliberately: the question\n * \"which lists did this reach\" has a plural answer everywhere it is asked,\n * and a caller that unwraps a single id today is a caller to revisit if a\n * send ever addresses two.\n */\nexport function campaignSendListIds(send: CampaignSend): string[] {\n return send.audience === 'list' && send.listId ? [send.listId] : []\n}\n\n/**\n * A number summed across sends, with how much of the campaign it covers.\n *\n * `value` is `null` when NO send recorded the field — which is not zero, for\n * the reason `campaign-report.ts` gives at length: an unrecorded delivery\n * count and a delivery count of zero lead a merchant to opposite conclusions\n * about their sending domain.\n *\n * `recorded` below `sends` means the total is a floor. A campaign whose\n * older sends predate the delivery webhook has a real number that describes\n * part of itself, and saying which part is the difference between a total and\n * a guess.\n */\nexport interface CampaignAggregate {\n value: number | null\n /** Sends that recorded this field. */\n recorded: number\n /** Sends in the campaign. */\n sends: number\n}\n\n/** Every rolled-up figure for one campaign. */\nexport interface CampaignRollup {\n /** Sends that have actually gone out. */\n sends: number\n /** Sends still waiting for their send time, having delivered nothing. */\n scheduled: number\n /**\n * Sends part way through an audience larger than one batch.\n *\n * Counted apart from `scheduled` and from `sends`, because it is neither:\n * mail has gone out, and more is going to. Both of those are facts a\n * merchant reading a campaign row needs, and the stored status carries\n * only the first.\n */\n sending: number\n /**\n * Emails that have been created and not yet written or sent.\n *\n * Counted apart from `sends` and from `scheduled`, because a draft is\n * neither: it has mailed nobody, and it is not on the clock to.\n */\n drafts: number\n addressed: CampaignAggregate\n sent: CampaignAggregate\n delivered: CampaignAggregate\n opens: CampaignAggregate\n uniqueOpens: CampaignAggregate\n clicks: CampaignAggregate\n uniqueClicks: CampaignAggregate\n bounced: CampaignAggregate\n complained: CampaignAggregate\n unsubscribes: CampaignAggregate\n /** Distinct openers over delivered, across every send that recorded both. */\n openRate: SendRate | null\n /** Distinct clickers over delivered. */\n clickRate: SendRate | null\n /** Unsubscribes over delivered. */\n unsubscribeRate: SendRate | null\n /** The most recent send time in the campaign, for ordering a list. */\n lastSentAtMs: number | null\n}\n\nconst aggregate = (\n sends: CampaignSend[],\n read: (stats: SendStats) => number | undefined,\n): CampaignAggregate => {\n let total = 0\n let recorded = 0\n for (const send of sends) {\n const value = read(send.stats ?? {})\n // `undefined` is \"this send never recorded it\"; a recorded 0 counts as\n // measured, and moves `recorded` without moving the total.\n if (value === undefined || value === null) continue\n const numeric = Number(value)\n if (!Number.isFinite(numeric)) continue\n total += numeric\n recorded += 1\n }\n return { value: recorded ? total : null, recorded, sends: sends.length }\n}\n\n/** When a send happened, in epoch milliseconds, or null. */\nexport function campaignSendAtMs(send: CampaignSend): number | null {\n const seconds = send.sentAt?.seconds\n if (typeof seconds === 'number') return seconds * 1000\n if (typeof send.sendAtMs === 'number') return send.sendAtMs\n return null\n}\n\n/**\n * Rolls a campaign's sends into one set of figures.\n *\n * Rates are taken over the sends that recorded BOTH sides, so a campaign\n * whose first send predates the delivery webhook reports the open rate of the\n * sends that can be measured rather than one deflated by a send with no\n * denominator.\n */\nexport function campaignRollup(sends: CampaignSend[]): CampaignRollup {\n /*==========================================\n * ONLY MAIL THAT HAS GONE OUT IS A SEND.\n *\n * An email exists from the moment it is created, so this collection now\n * holds records in three states that have mailed nobody — `draft`,\n * `scheduled` and the `sending` claim — and every figure below is about\n * mail that was delivered.\n *\n * Two separate faults if they are left in. The COUNT would report a\n * campaign as having sent three emails when it has sent two and is still\n * writing the third. And every aggregate carries `sends` as the denominator\n * of its own \"recorded by N of M\" label, so an unsent record would enlarge\n * the M — publishing a coverage figure that says the campaign is missing\n * data it was never going to have.\n *=========================================*/\n /*\n * A SEND THAT IS STILL GOING HAS STILL GONE.\n *\n * `emailIsUnsent` reads the stored status, and an email delivering an\n * audience larger than one batch is stored as `scheduled` between runs —\n * so reading it literally drops a send that has put five hundred messages\n * in five hundred inboxes out of every total on the campaign, and counts it\n * under \"scheduled\" as though nothing had happened. `campaignSendProgress`\n * is what tells \"waiting for its time\" from \"part way through\", and only\n * the first is genuinely unsent.\n *\n * A DRAFT is settled by the status because the progress states do not cover\n * it — see `campaignSendDisplay`.\n */\n const notYet = (send: CampaignSend): boolean =>\n String(send.status ?? '') === 'draft' ||\n campaignSendProgress(send).state === 'pending'\n const gone = sends.filter((send) => !notYet(send))\n const measurable = sends.filter(\n (send) => send.stats?.delivered !== undefined,\n )\n const deliveredTotal = aggregate(measurable, (stats) => stats.delivered)\n return {\n sends: gone.filter((send) => send.status !== 'canceled').length,\n // Waiting for its time with nothing delivered — which is what\n // \"scheduled\" meant before an email could be delivered over several runs,\n // and is now narrower than the stored status of that name.\n scheduled: sends.filter(\n (send) => campaignSendProgress(send).state === 'pending',\n ).length,\n sending: sends.filter(\n (send) => campaignSendProgress(send).state === 'sending',\n ).length,\n drafts: sends.filter((send) => send.status === 'draft').length,\n addressed: aggregate(gone, (stats) => stats.recipients),\n sent: aggregate(gone, (stats) => stats.sent),\n delivered: aggregate(gone, (stats) => stats.delivered),\n opens: aggregate(gone, (stats) => stats.opens),\n uniqueOpens: aggregate(gone, (stats) => stats.uniqueOpens),\n clicks: aggregate(gone, (stats) => stats.clicks),\n uniqueClicks: aggregate(gone, (stats) => stats.uniqueClicks),\n bounced: aggregate(gone, (stats) => stats.bounced),\n complained: aggregate(gone, (stats) => stats.complained),\n unsubscribes: aggregate(gone, (stats) => stats.unsubscribes),\n openRate: sendRate(\n aggregate(measurable, (stats) => stats.uniqueOpens).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n clickRate: sendRate(\n aggregate(measurable, (stats) => stats.uniqueClicks).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n unsubscribeRate: sendRate(\n aggregate(measurable, (stats) => stats.unsubscribes).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n lastSentAtMs: sends.reduce<number | null>((latest, send) => {\n const at = campaignSendAtMs(send)\n return at !== null && (latest === null || at > latest) ? at : latest\n }, null),\n }\n}\n\n/**\n * Where a campaign stands against its own window.\n *\n * Derived at read time and never persisted — the `status` values on a SEND\n * (`sent`, `scheduled`, `canceled`, `failed`) are stored strings that a\n * processor branches on, and a display state sharing those spellings would\n * eventually be written back.\n */\nexport type CampaignWindowState = 'undated' | 'upcoming' | 'running' | 'ended'\n\nexport function campaignWindowState(\n campaign: Pick<EmailCampaign, 'startAtMs' | 'endAtMs'>,\n nowMs: number,\n): CampaignWindowState {\n const start = campaign.startAtMs ?? null\n const end = campaign.endAtMs ?? null\n if (start === null && end === null) return 'undated'\n if (start !== null && nowMs < start) return 'upcoming'\n if (end !== null && nowMs > end) return 'ended'\n return 'running'\n}\n\n/** One row of the campaigns table. */\nexport interface CampaignListRow {\n /** The id the detail route resolves — a container id, or a send id. */\n id: string\n name: string\n /**\n * True when this row IS a send with no container.\n *\n * A campaign sent before containers existed is shown as a campaign of one\n * rather than dropped from the list, and the detail route falls back to the\n * send's own report for it. Nothing about that send is rewritten.\n */\n legacy: boolean\n startAtMs: number | null\n endAtMs: number | null\n listIds: string[]\n sends: CampaignSend[]\n rollup: CampaignRollup\n windowState: CampaignWindowState\n /** For ordering: the campaign's start, else its most recent send. */\n atMs: number | null\n}\n\n/**\n * The campaigns table's rows: every container, plus every send that belongs\n * to none.\n *\n * The second half is what makes the container additive. A merchant's history\n * is the sends they already have; a list that showed only containers would\n * read as an empty product on the day this shipped, and a backfill that\n * adopted each old send into a container of one would rewrite documents whose\n * ids are cited by mail already delivered. Adopting them AT READ TIME costs a\n * pass over a list already in memory and rewrites nothing.\n *\n * Newest first, on the start date where there is one and the last send\n * otherwise. Rows with no date at all sort last: they are campaigns nobody\n * has scheduled or sent, and dating them from nothing would be an invention.\n */\nexport function campaignListRows(\n campaigns: EmailCampaign[],\n sends: CampaignSend[],\n nowMs: number,\n): CampaignListRow[] {\n const byCampaign = new Map<string, CampaignSend[]>()\n const orphans: CampaignSend[] = []\n for (const send of sends) {\n const containerId = send.emailCampaignId\n if (!containerId) {\n orphans.push(send)\n continue\n }\n const existing = byCampaign.get(containerId)\n if (existing) existing.push(send)\n else byCampaign.set(containerId, [send])\n }\n\n const rows: CampaignListRow[] = campaigns.map((campaign) => {\n const own = byCampaign.get(campaign.$id) ?? []\n const rollup = campaignRollup(own)\n return {\n id: campaign.$id,\n name: campaign.name || 'Untitled campaign',\n legacy: false,\n startAtMs: campaign.startAtMs ?? null,\n endAtMs: campaign.endAtMs ?? null,\n listIds: campaign.listIds ?? [],\n sends: own,\n rollup,\n windowState: campaignWindowState(campaign, nowMs),\n atMs: campaign.startAtMs ?? rollup.lastSentAtMs ?? null,\n }\n })\n\n for (const send of orphans) {\n const rollup = campaignRollup([send])\n const at = campaignSendAtMs(send)\n rows.push({\n id: send.$id,\n name: send.subject || 'Untitled campaign',\n legacy: true,\n startAtMs: at,\n endAtMs: at,\n listIds: [],\n sends: [send],\n rollup,\n // A send has no window of its own; it happened, or it is going to.\n windowState:\n send.status === 'scheduled'\n ? 'upcoming'\n : at === null\n ? 'undated'\n : 'ended',\n atMs: at,\n })\n }\n\n return rows.sort((a, b) => {\n if (a.atMs === null && b.atMs === null) return a.name.localeCompare(b.name)\n if (a.atMs === null) return 1\n if (b.atMs === null) return -1\n return b.atMs - a.atMs\n })\n}\n"],"names":["readListUnsubscribeSetting","sendRate","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","EMAIL_CAMPAIGNS_COLLECTION","CAMPAIGN_SENDS_COLLECTION","orgEmailCampaignsPath","orgId","orgCampaignSendsPath","orgCampaignSequenceReportsPath","CAMPAIGN_SEND_HOST_FIELD","CAMPAIGN_ORG_SCOPE","campaignHostScope","hostId","campaignSendVisibleTo","campaignSiteIds","campaign","tokens","visibleTo","includes","ids","token","startsWith","length","push","slice","campaignPlacedOnHost","campaignVisibleTo","siteIds","unique","Set","filter","id","map","CAMPAIGN_SEND_CONTAINER_FIELD","CAMPAIGN_UTM_LABELS_MAX","normalizeCampaignUtmLabel","raw","label","trim","toLowerCase","test","campaignUtmLabels","parts","Array","isArray","split","labels","part","campaignListUnsubscribe","listUnsubscribe","campaignForcedListUnsubscribeSends","sends","send","forced","campaignSendHeldForReview","status","String","staffReview","state","campaignHeldForReviewNotice","reference","progressCount","value","Math","floor","Number","isFinite","campaignSendProgress","reached","stats","sent","rawAudience","audienceSize","audience","undefined","resume","remaining","batch","nextAtMs","of","toLocaleString","why","campaignSendDisplay","progress","campaignSendIsMidFlight","campaignSendListIds","listId","aggregate","read","total","recorded","numeric","campaignSendAtMs","seconds","sentAt","sendAtMs","campaignRollup","deliveredTotal","notYet","gone","measurable","delivered","scheduled","sending","drafts","addressed","recipients","opens","uniqueOpens","clicks","uniqueClicks","bounced","complained","unsubscribes","openRate","clickRate","unsubscribeRate","lastSentAtMs","reduce","latest","at","campaignWindowState","nowMs","start","startAtMs","end","endAtMs","campaignListRows","campaigns","byCampaign","Map","orphans","containerId","emailCampaignId","existing","get","set","rows","own","$id","rollup","name","legacy","listIds","windowState","atMs","subject","sort","a","b","localeCompare"],"mappings":"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"}
@@ -56,6 +56,15 @@ import type { CampaignCaveat } from './campaign-report';
56
56
  * this reader states that where the figure is drawn, and web-channel records
57
57
  * are read as records — see {@link campaignConversionsCoverage}.
58
58
  *
59
+ * ## A PAGE OR A DECLARED LABEL NAMES THE CAMPAIGN ITSELF (AGL-3461)
60
+ *
61
+ * Two more touches name a campaign CONTAINER rather than a send: a page filed
62
+ * under the campaign, and a `utm_campaign` label the campaign declares as its
63
+ * own. Their records carry the container's id as `campaignId`, so the
64
+ * campaign's page counts them with the same `kind` + `campaignId` aggregation
65
+ * it counts its sends' conversions with — keyed by a document id, so bounded
66
+ * by the campaigns that exist, and needing no rollup document of their own.
67
+ *
59
68
  * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL
60
69
  *
61
70
  * Direct traffic writes no record. There is deliberately no
@@ -87,7 +96,36 @@ export type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number];
87
96
  * the campaign whose mail was clicked, plus the sequence and the enrollment
88
97
  * it came through, so a record can say which rep's outreach it credits.
89
98
  */
90
- export type CampaignTouchChannel = 'email' | 'web' | 'sequence';
99
+ export type CampaignTouchChannel = 'email' | 'web' | 'sequence' | 'page';
100
+ /**
101
+ * THE RULE an identify moment is credited under (AGL-3461), stamped on every
102
+ * record as `model` so a reader can print what was counted.
103
+ *
104
+ * `last-click` — the revenue join's rule, which an order still uses — named
105
+ * the later of a campaign email click and a labeled link. A page filed under a
106
+ * campaign is a touch too now, and it is not a click, so the identify moment
107
+ * says so:
108
+ *
109
+ * - the later of a campaign email click and a link carrying `utm_` labels
110
+ * wins, exactly as before;
111
+ * - a page filed under a campaign competes with that winner. Within
112
+ * {@link CAMPAIGN_VISIT_MINUTES} of it the two are one visit: a click that
113
+ * names a campaign — its mail, or a label the campaign declares — keeps
114
+ * the credit for the page it landed on, and a label that names none
115
+ * yields to the page. Further apart, the later one wins;
116
+ * - all of it inside the seven-day window, as before.
117
+ */
118
+ export declare const CAMPAIGN_TOUCH_MODEL = "last-touch";
119
+ /** How close a page view and a click are to be one visit, in minutes. */
120
+ export declare const CAMPAIGN_VISIT_MINUTES = 30;
121
+ /** The same, in milliseconds. */
122
+ export declare const CAMPAIGN_VISIT_MS: number;
123
+ /**
124
+ * The rule a record or a rollup was credited under, as one sentence a reader
125
+ * can check the figure against. Keyed off the STORED model, so a conversion
126
+ * credited under an older rule prints the rule it was credited under.
127
+ */
128
+ export declare function campaignCreditRule(model: string | null | undefined, windowDays: number | null | undefined): string;
91
129
  /**
92
130
  * The record each conversion kind credits, as its owner publishes the kind
93
131
  * (`plugin-record-counts`, `plugin-record-lists`): a credited form names the
@@ -154,6 +192,10 @@ export interface CampaignConversionRecord {
154
192
  touchedAtMs?: number;
155
193
  /** When the visitor became identifiable, epoch ms. */
156
194
  convertedAtMs?: number;
195
+ /** The screen the visitor viewed, for a `page` touch (AGL-3461). */
196
+ screenId?: string;
197
+ /** That page's path, for a `page` touch. */
198
+ path?: string;
157
199
  model?: string;
158
200
  windowDays?: number;
159
201
  }
@@ -297,8 +339,10 @@ export declare function campaignConversionsCoverage(options: {
297
339
  *
298
340
  * The email channel names a campaign document, so the screen can link to it,
299
341
  * and so does a sequence touch: the campaign the sequence is in is a
300
- * container with a page. The web channel names a label the marketer typed,
301
- * which is text and never a link — there is nothing at the other end of it.
342
+ * container with a page — as does a page touch, the campaign the viewed page
343
+ * is filed under (AGL-3461). The web channel names a label the marketer
344
+ * typed, which is text unless a campaign declares the label as its own; then
345
+ * the record carries that campaign's id too and the screen links it.
302
346
  *
303
347
  * The `utm_` triple is joined in the order a marketer set it, with the parts
304
348
  * that are absent left out rather than filled with a placeholder.
@@ -60,6 +60,15 @@
60
60
  * this reader states that where the figure is drawn, and web-channel records
61
61
  * are read as records — see {@link campaignConversionsCoverage}.
62
62
  *
63
+ * ## A PAGE OR A DECLARED LABEL NAMES THE CAMPAIGN ITSELF (AGL-3461)
64
+ *
65
+ * Two more touches name a campaign CONTAINER rather than a send: a page filed
66
+ * under the campaign, and a `utm_campaign` label the campaign declares as its
67
+ * own. Their records carry the container's id as `campaignId`, so the
68
+ * campaign's page counts them with the same `kind` + `campaignId` aggregation
69
+ * it counts its sends' conversions with — keyed by a document id, so bounded
70
+ * by the campaigns that exist, and needing no rollup document of their own.
71
+ *
63
72
  * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL
64
73
  *
65
74
  * Direct traffic writes no record. There is deliberately no
@@ -91,6 +100,40 @@
91
100
  'contact',
92
101
  'booking'
93
102
  ];
103
+ /**
104
+ * THE RULE an identify moment is credited under (AGL-3461), stamped on every
105
+ * record as `model` so a reader can print what was counted.
106
+ *
107
+ * `last-click` — the revenue join's rule, which an order still uses — named
108
+ * the later of a campaign email click and a labeled link. A page filed under a
109
+ * campaign is a touch too now, and it is not a click, so the identify moment
110
+ * says so:
111
+ *
112
+ * - the later of a campaign email click and a link carrying `utm_` labels
113
+ * wins, exactly as before;
114
+ * - a page filed under a campaign competes with that winner. Within
115
+ * {@link CAMPAIGN_VISIT_MINUTES} of it the two are one visit: a click that
116
+ * names a campaign — its mail, or a label the campaign declares — keeps
117
+ * the credit for the page it landed on, and a label that names none
118
+ * yields to the page. Further apart, the later one wins;
119
+ * - all of it inside the seven-day window, as before.
120
+ */ export const CAMPAIGN_TOUCH_MODEL = 'last-touch';
121
+ /** How close a page view and a click are to be one visit, in minutes. */ export const CAMPAIGN_VISIT_MINUTES = 30;
122
+ /** The same, in milliseconds. */ export const CAMPAIGN_VISIT_MS = CAMPAIGN_VISIT_MINUTES * 60 * 1000;
123
+ /**
124
+ * The rule a record or a rollup was credited under, as one sentence a reader
125
+ * can check the figure against. Keyed off the STORED model, so a conversion
126
+ * credited under an older rule prints the rule it was credited under.
127
+ */ export function campaignCreditRule(model, windowDays) {
128
+ const days = Number(windowDays != null ? windowDays : 0) || EMAIL_ATTRIBUTION_WINDOW_DAYS;
129
+ if (model === CAMPAIGN_TOUCH_MODEL) {
130
+ return `Credited to the visitor’s latest campaign touch within ${days} days — a ` + 'campaign email they clicked, a link labeled for a campaign, or a page ' + `filed under one. A page viewed within ${CAMPAIGN_VISIT_MINUTES} minutes of ` + 'a click is that click’s visit, and the click keeps the credit when it ' + 'names a campaign.';
131
+ }
132
+ if (model === EMAIL_ATTRIBUTION_MODEL) {
133
+ return `Credited to the last campaign whose link they clicked, within ${days} days of that click.`;
134
+ }
135
+ return `Credited under the ${String(model != null ? model : 'recorded')} model, within ${days} days.`;
136
+ }
94
137
  /**
95
138
  * The record each conversion kind credits, as its owner publishes the kind
96
139
  * (`plugin-record-counts`, `plugin-record-lists`): a credited form names the
@@ -181,7 +224,7 @@ export const CAMPAIGN_CONVERSION_KIND_COPY = {
181
224
  });
182
225
  caveats.push({
183
226
  id: 'conversions-web-not-rolled-up',
184
- message: 'Campaign emails only. A conversion credited to a link tagged with ' + 'utm_ parameters is recorded against that label rather than against ' + 'a campaign, so it is not in the figures above — those conversions ' + 'are listed under Conversions in the marketing console.'
227
+ message: 'This email only. A conversion credited to a page filed under the ' + 'campaign, or to a link labeled for it, is counted on the ' + 'campaign’s own page; one credited to a utm_ label no campaign ' + 'declares is listed under Conversions in the marketing console.'
185
228
  });
186
229
  }
187
230
  return {
@@ -239,8 +282,10 @@ export const CAMPAIGN_CONVERSION_KIND_COPY = {
239
282
  *
240
283
  * The email channel names a campaign document, so the screen can link to it,
241
284
  * and so does a sequence touch: the campaign the sequence is in is a
242
- * container with a page. The web channel names a label the marketer typed,
243
- * which is text and never a link — there is nothing at the other end of it.
285
+ * container with a page — as does a page touch, the campaign the viewed page
286
+ * is filed under (AGL-3461). The web channel names a label the marketer
287
+ * typed, which is text unless a campaign declares the label as its own; then
288
+ * the record carries that campaign's id too and the screen links it.
244
289
  *
245
290
  * The `utm_` triple is joined in the order a marketer set it, with the parts
246
291
  * that are absent left out rather than filled with a placeholder.
@@ -248,7 +293,7 @@ export const CAMPAIGN_CONVERSION_KIND_COPY = {
248
293
  * one of them invites the reader to look for a campaign called "(none)".
249
294
  */ export function campaignTouchLabel(record) {
250
295
  if (!record) return '';
251
- if (record.channel === 'email' || record.channel === 'sequence') {
296
+ if (record.channel === 'email' || record.channel === 'sequence' || record.channel === 'page') {
252
297
  var _record_campaignId;
253
298
  return String((_record_campaignId = record.campaignId) != null ? _record_campaignId : '');
254
299
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-conversions.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The window and the model name come from `@aglyn/shared-util-email`, for the\n * reason `campaign-revenue.ts` gives: the writer is in `tenant-data-admin`,\n * which may not import a feature plugin, and a rule defined on both sides of\n * the join drifts into a figure credited under one and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport type { CampaignCaveat } from './campaign-report'\n\n/**\n * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.\n *\n * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name\n * their buyer. This answers what it CAUSED among people who were anonymous\n * until the moment they became somebody: they arrive from a campaign link,\n * browse, and only become identifiable when they submit a form, sign up, book\n * or check out. Same window, same last-click rule, same `model`/`windowDays`\n * stamped on every record, so the two figures on one screen are two readings\n * of one rule rather than two rules that happen to agree.\n *\n * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural\n *\n * One form submission by a new person writes a submission record, a contact\n * and a lead — three true statements about one visitor action. Adding them\n * would treble every campaign's conversions, and the reader would have no way\n * to see it had happened, because the sum looks like a bigger version of a\n * real number.\n *\n * So {@link CampaignConversionsReport} carries no total, no `all`, no\n * `conversions` scalar, and there is no function here that reduces the kinds.\n * A screen that wanted one would have to write the addition itself. The\n * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so\n * the reader is told WHY the four figures stand apart, rather than being left\n * to assume the product forgot to add them up.\n *\n * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one\n *\n * A conversion is credited to one of two touches. An EMAIL touch names a\n * campaign document — a real entity with a real id — so its conversions roll\n * up under it beside the revenue the same join credits it with. A WEB touch\n * is a `utm_` label a marketer typed into a URL: no document, no id, and no\n * bound on how many distinct values exist, because anybody who can vary a\n * query string can mint another one.\n *\n * A rollup keyed on that label is a map a stranger can grow, which is the\n * same unbounded key space the analytics collector caps its per-day label map\n * against. So the writer increments the rollup for the email channel ONLY,\n * this reader states that where the figure is drawn, and web-channel records\n * are read as records — see {@link campaignConversionsCoverage}.\n *\n * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL\n *\n * Direct traffic writes no record. There is deliberately no\n * `utm_source=direct` placeholder, no referrer inference and no \"most recent\n * campaign on this site\" fallback: a conversion nobody can be credited with\n * is a conversion nobody is credited with.\n *\n * That makes the absence of a record load-bearing, and it makes a screen\n * showing only attributed conversions a lie by omission — it renders \"we\n * credited three of these\" as \"three of these happened\".\n * {@link campaignConversionsCoverage} exists so the unattributed count is a\n * figure on the page rather than an inference nobody makes.\n */\n\n/*\n * The constants are NOT re-exported here, though `campaign-revenue.ts`\n * re-exports the same two. Both modules sit behind one barrel, and a name\n * exported twice through it is a name a bundler has to disambiguate. Every\n * screen reads the rule off `model`/`windowDays` on the report anyway, which\n * is the stored value rather than today's constant — a campaign credited\n * under an older window has to print the window it was credited under.\n */\n\n/**\n * Which identify moment a record credits.\n *\n * An array first, because the ORDER is the reading order on every screen and\n * a second list of these is a second chance to leave one out. The union is\n * derived from it rather than written twice.\n */\nexport const CAMPAIGN_CONVERSION_KINDS = [\n 'form',\n 'lead',\n 'contact',\n 'booking',\n] as const\n\n/** One identify moment. Mirrors the writer's `CampaignConversionKind`. */\nexport type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number]\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a one-to-one email a rep's sequence sent: the\n * touch names the CAMPAIGN the sequence is in, like an email touch names\n * the campaign whose mail was clicked, plus the sequence and the enrollment\n * it came through, so a record can say which rep's outreach it credits.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/**\n * The record each conversion kind credits, as its owner publishes the kind\n * (`plugin-record-counts`, `plugin-record-lists`): a credited form names the\n * SUBMISSION it credits, the rest the record of their own name. The owners\n * count and read those records; this plugin only names the kind.\n */\nexport const CAMPAIGN_CONVERSION_RECORD_KINDS: Readonly<\n Record<CampaignConversionKind, string>\n> = {\n form: 'formSubmission',\n lead: 'lead',\n contact: 'contact',\n booking: 'booking',\n}\n\n/**\n * What a reader calls one kind, and what the count means.\n *\n * The note is the half that stops the addition. Each one names a DIFFERENT\n * population of the same visitors, and saying so under every figure is what\n * makes four numbers standing apart read as deliberate rather than as a\n * missing total.\n */\nexport interface CampaignConversionKindCopy {\n label: string\n note: string\n}\n\nexport const CAMPAIGN_CONVERSION_KIND_COPY: Readonly<\n Record<CampaignConversionKind, CampaignConversionKindCopy>\n> = {\n form: {\n label: 'Form submissions',\n note: 'submissions credited to this campaign',\n },\n lead: { label: 'Leads', note: 'new leads credited to this campaign' },\n contact: {\n label: 'Contacts',\n note: 'new contacts credited to this campaign',\n },\n booking: { label: 'Bookings', note: 'bookings credited to this campaign' },\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/conversions`.\n *\n * Its own document beside `reports/revenue`, for the reason that one is: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with what the campaign caused would enlarge\n * every one of those reads.\n */\nexport interface CampaignConversionsRollup {\n byKind?: Partial<Record<CampaignConversionKind, number>>\n /** The model these conversions were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * The stored shape of `hosts/{hostId}/campaignAttributions/{kind}:{refId}`.\n *\n * Read-side only, and every field optional: this is a document somebody\n * else's writer produced, and a reader that assumed a field was present would\n * throw on the first record written by an older version of it.\n */\nexport interface CampaignConversionRecord {\n kind?: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId?: string\n channel?: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or,\n * for a `sequence` touch, the campaign container the sequence is in.\n */\n campaignId?: string\n /** The sequence, when the touch was a sequence email (AGL-3254). */\n sequenceId?: string\n /** The enrollment the sequence email went out under (AGL-3254). */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs?: number\n /** When the visitor became identifiable, epoch ms. */\n convertedAtMs?: number\n model?: string\n windowDays?: number\n}\n\n/**\n * The document id for one conversion — `{kind}:{refId}`.\n *\n * The reader's half of the writer's `campaignConversionId`. It is restated\n * here rather than imported because the writer lives in `tenant-data-admin`,\n * which a UI library may not import, and it is one line whose shape is\n * asserted in this module's spec.\n *\n * Answers `null` for an unusable pair rather than building `form:undefined`,\n * which would be a valid document path pointing at a record that can never\n * exist — a keyed read that silently reports \"not attributed\" for every\n * record on the screen.\n */\nexport function campaignConversionId(\n kind: string | null | undefined,\n refId: string | null | undefined,\n): string | null {\n const k = String(kind ?? '')\n const ref = String(refId ?? '')\n if (!k || !ref) return null\n if (!(CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(k)) return null\n // A slash would leave the collection; the id scheme already spends the\n // colon, and a ref carrying one would make the pair ambiguous.\n if (ref.includes('/') || ref.includes(':')) return null\n return `${k}:${ref}`\n}\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/** One kind's figure on screen. Independent of every other kind's. */\nexport interface CampaignConversionKindReport {\n kind: CampaignConversionKind\n label: string\n /**\n * Conversions of this kind, or `null` when the rollup holds no entry.\n *\n * `null` rather than 0 for {@link CampaignConversionsReport.recorded}'s\n * reason, applied per kind: a site with no booking form has never written a\n * booking conversion, and rendering that as a measured zero invites the\n * reader to conclude the campaign failed at something it never attempted.\n */\n value: number | null\n note: string\n}\n\n/** Everything the conversions section renders. */\nexport interface CampaignConversionsReport {\n /**\n * One entry per kind, always all four and always in\n * {@link CAMPAIGN_CONVERSION_KINDS} order.\n *\n * There is deliberately NO total beside this array, and no field anywhere\n * on this report that holds one. See the module docblock.\n */\n kinds: CampaignConversionKindReport[]\n /**\n * Whether the rollup document exists at all.\n *\n * `false` is NOT \"this campaign caused nothing\" — it is also every campaign\n * sent before the join existed, and every campaign whose conversions all\n * arrived through the web channel, which writes no rollup. The screen\n * renders the difference rather than printing a zero for all three.\n */\n recorded: boolean\n /** At least one kind holds a figure. */\n any: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns the stored rollup into the conversions section.\n *\n * Takes the rollup and nothing else. Unlike the revenue report there is no\n * denominator to hand in: a conversion RATE over delivered messages would be\n * the same defect the revenue section refuses for orders — one visitor can\n * submit two forms, so the quotient passes 100% without anything being wrong\n * — and counting distinct people would need a document per person per\n * campaign, the per-recipient read this whole surface exists to refuse.\n */\nexport function campaignConversionsReport(options: {\n rollup: CampaignConversionsRollup | undefined\n}): CampaignConversionsReport {\n const { rollup } = options\n const stored = rollup?.byKind ?? {}\n const caveats: CampaignCaveat[] = []\n\n const kinds: CampaignConversionKindReport[] = CAMPAIGN_CONVERSION_KINDS.map(\n (kind) => {\n const raw = stored[kind]\n const copy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n return {\n kind,\n label: copy.label,\n // `undefined` means the rollup has no entry for this kind, which is\n // not a measured zero. A stored 0 is impossible — the writer only\n // ever increments — but it is read as unrecorded for the same reason.\n value: raw === undefined || count(raw) === 0 ? null : count(raw),\n note: copy.note,\n }\n },\n )\n\n const any = kinds.some((entry) => entry.value !== null)\n\n if (any) {\n caveats.push({\n id: 'conversions-kinds-overlap',\n message:\n 'These figures count different things about the same visits and are ' +\n 'deliberately not added together. One person filling in one form can ' +\n 'appear as a submission, a contact and a lead, so a total would ' +\n 'count that visit three times.',\n })\n caveats.push({\n id: 'conversions-web-not-rolled-up',\n message:\n 'Campaign emails only. A conversion credited to a link tagged with ' +\n 'utm_ parameters is recorded against that label rather than against ' +\n 'a campaign, so it is not in the figures above — those conversions ' +\n 'are listed under Conversions in the marketing console.',\n })\n }\n\n return {\n kinds,\n recorded: rollup !== undefined,\n any,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/**\n * How much of one kind was credited to anything at all.\n *\n * ## Why this figure has to exist\n *\n * The join writes nothing for a conversion it cannot credit, so a list of\n * attribution records is a list of the SUCCESSES. Rendering only that turns\n * \"we credited four of these\" into \"four of these happened\", and the reader\n * draws a conclusion about their campaigns from a number that is mostly a\n * fact about how many visitors arrived without a campaign link.\n *\n * So the screen shows both halves and this computes the second. `attributed`\n * is counted over the attribution records; `total` over the records the\n * conversions themselves live in.\n *\n * ## Why the difference is a CEILING and says so\n *\n * The two counts come from different collections with different histories,\n * and the gap between them holds three things that are not the same:\n *\n * - conversions by visitors who arrived directly, which is what the figure\n * is meant to describe;\n * - conversions from before the join existed, which were never eligible; and\n * - for contacts, conversions on the org's OTHER sites, because contacts are\n * shared across an org while attributions are per host.\n *\n * None of those can be separated from the others without a field nobody\n * writes, so `unattributed` is reported as an upper bound and\n * {@link CampaignConversionsCoverage.exact} is `false` whenever the reasons\n * apply. A figure presented as exact when it is not is the failure this\n * whole surface is built to avoid.\n */\nexport interface CampaignConversionsCoverage {\n kind: CampaignConversionKind\n /** Conversions of this kind credited to some campaign or label. */\n attributed: number\n /** Conversions of this kind that exist, credited or not. */\n total: number\n /** `total - attributed`, clamped at zero. An upper bound, not a count. */\n unattributed: number\n /**\n * Whether `unattributed` may be read as \"arrived directly\".\n *\n * Always `false` today — the three reasons in the docblock all apply to\n * every host — and kept as a field rather than hardcoded in the copy so a\n * screen asks the model rather than restating its conclusion.\n */\n exact: boolean\n caveats: CampaignCaveat[]\n}\n\n/**\n * The attributed/unattributed split for one kind.\n *\n * `total` is `null` when it could not be counted — the aggregation failed, or\n * the collection is not readable from this surface — and the split is then\n * withheld entirely rather than defaulting `total` to `attributed`, which\n * would render every conversion as attributed and is the single most\n * flattering wrong answer available here.\n */\nexport function campaignConversionsCoverage(options: {\n kind: CampaignConversionKind\n attributed: number | null | undefined\n total: number | null | undefined\n /** The kind's records live outside this host, so `total` over-counts. */\n crossHostTotal?: boolean\n}): CampaignConversionsCoverage | null {\n const { kind, crossHostTotal } = options\n if (options.total == null || options.attributed == null) return null\n const attributed = count(options.attributed)\n const total = count(options.total)\n const caveats: CampaignCaveat[] = []\n\n /*\n * CLAMPED, and only here at the point of display. The two counts are taken\n * from two collections a moment apart, so a conversion recorded between\n * them makes `attributed` briefly exceed `total`; a negative count of\n * things that did not happen is not a sentence anybody can act on.\n */\n const unattributed = Math.max(0, total - attributed)\n\n caveats.push({\n id: 'conversions-unattributed-is-a-ceiling',\n message:\n `${unattributed.toLocaleString()} of these are not credited to any ` +\n 'campaign. Most arrived without following a campaign link and are ' +\n 'recorded as direct — nothing is guessed from a referrer, and no ' +\n 'campaign is credited for being the most recent one to run. The rest ' +\n 'are records from before campaign attribution was recorded at all, so ' +\n 'read this as an upper bound rather than a count of direct arrivals.',\n })\n if (crossHostTotal) {\n caveats.push({\n id: 'conversions-total-crosses-hosts',\n message:\n 'Contacts are shared across every site in this organization, while ' +\n 'attributions belong to one site. The uncredited figure therefore ' +\n 'includes contacts that were created on another site and could ' +\n 'never have been credited here.',\n })\n }\n\n return {\n kind,\n attributed,\n total,\n unattributed,\n // Never true while the reasons above stand. The field exists so a screen\n // reads the model rather than restating its conclusion in JSX.\n exact: false,\n caveats,\n }\n}\n\n/**\n * How a record names the thing it was credited to.\n *\n * The email channel names a campaign document, so the screen can link to it,\n * and so does a sequence touch: the campaign the sequence is in is a\n * container with a page. The web channel names a label the marketer typed,\n * which is text and never a link — there is nothing at the other end of it.\n *\n * The `utm_` triple is joined in the order a marketer set it, with the parts\n * that are absent left out rather than filled with a placeholder.\n * `google / cpc` and `google / cpc / (none)` describe the same link, and only\n * one of them invites the reader to look for a campaign called \"(none)\".\n */\nexport function campaignTouchLabel(\n record: CampaignConversionRecord | null | undefined,\n): string {\n if (!record) return ''\n if (record.channel === 'email' || record.channel === 'sequence') {\n return String(record.campaignId ?? '')\n }\n const parts = [record.source, record.medium, record.campaign]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n return parts.join(' / ')\n}\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_RECORD_KINDS","form","lead","contact","booking","CAMPAIGN_CONVERSION_KIND_COPY","label","note","campaignConversionId","kind","refId","k","String","ref","includes","count","raw","value","Math","floor","Number","isFinite","campaignConversionsReport","options","rollup","stored","byKind","caveats","kinds","map","copy","undefined","any","some","entry","push","id","message","recorded","model","windowDays","campaignConversionsCoverage","crossHostTotal","total","attributed","unattributed","max","toLocaleString","exact","campaignTouchLabel","record","channel","campaignId","parts","source","medium","campaign","part","trim","filter","Boolean","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-conversions.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The window and the model name come from `@aglyn/shared-util-email`, for the\n * reason `campaign-revenue.ts` gives: the writer is in `tenant-data-admin`,\n * which may not import a feature plugin, and a rule defined on both sides of\n * the join drifts into a figure credited under one and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport type { CampaignCaveat } from './campaign-report'\n\n/**\n * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.\n *\n * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name\n * their buyer. This answers what it CAUSED among people who were anonymous\n * until the moment they became somebody: they arrive from a campaign link,\n * browse, and only become identifiable when they submit a form, sign up, book\n * or check out. Same window, same last-click rule, same `model`/`windowDays`\n * stamped on every record, so the two figures on one screen are two readings\n * of one rule rather than two rules that happen to agree.\n *\n * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural\n *\n * One form submission by a new person writes a submission record, a contact\n * and a lead — three true statements about one visitor action. Adding them\n * would treble every campaign's conversions, and the reader would have no way\n * to see it had happened, because the sum looks like a bigger version of a\n * real number.\n *\n * So {@link CampaignConversionsReport} carries no total, no `all`, no\n * `conversions` scalar, and there is no function here that reduces the kinds.\n * A screen that wanted one would have to write the addition itself. The\n * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so\n * the reader is told WHY the four figures stand apart, rather than being left\n * to assume the product forgot to add them up.\n *\n * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one\n *\n * A conversion is credited to one of two touches. An EMAIL touch names a\n * campaign document — a real entity with a real id — so its conversions roll\n * up under it beside the revenue the same join credits it with. A WEB touch\n * is a `utm_` label a marketer typed into a URL: no document, no id, and no\n * bound on how many distinct values exist, because anybody who can vary a\n * query string can mint another one.\n *\n * A rollup keyed on that label is a map a stranger can grow, which is the\n * same unbounded key space the analytics collector caps its per-day label map\n * against. So the writer increments the rollup for the email channel ONLY,\n * this reader states that where the figure is drawn, and web-channel records\n * are read as records — see {@link campaignConversionsCoverage}.\n *\n * ## A PAGE OR A DECLARED LABEL NAMES THE CAMPAIGN ITSELF (AGL-3461)\n *\n * Two more touches name a campaign CONTAINER rather than a send: a page filed\n * under the campaign, and a `utm_campaign` label the campaign declares as its\n * own. Their records carry the container's id as `campaignId`, so the\n * campaign's page counts them with the same `kind` + `campaignId` aggregation\n * it counts its sends' conversions with — keyed by a document id, so bounded\n * by the campaigns that exist, and needing no rollup document of their own.\n *\n * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL\n *\n * Direct traffic writes no record. There is deliberately no\n * `utm_source=direct` placeholder, no referrer inference and no \"most recent\n * campaign on this site\" fallback: a conversion nobody can be credited with\n * is a conversion nobody is credited with.\n *\n * That makes the absence of a record load-bearing, and it makes a screen\n * showing only attributed conversions a lie by omission — it renders \"we\n * credited three of these\" as \"three of these happened\".\n * {@link campaignConversionsCoverage} exists so the unattributed count is a\n * figure on the page rather than an inference nobody makes.\n */\n\n/*\n * The constants are NOT re-exported here, though `campaign-revenue.ts`\n * re-exports the same two. Both modules sit behind one barrel, and a name\n * exported twice through it is a name a bundler has to disambiguate. Every\n * screen reads the rule off `model`/`windowDays` on the report anyway, which\n * is the stored value rather than today's constant — a campaign credited\n * under an older window has to print the window it was credited under.\n */\n\n/**\n * Which identify moment a record credits.\n *\n * An array first, because the ORDER is the reading order on every screen and\n * a second list of these is a second chance to leave one out. The union is\n * derived from it rather than written twice.\n */\nexport const CAMPAIGN_CONVERSION_KINDS = [\n 'form',\n 'lead',\n 'contact',\n 'booking',\n] as const\n\n/** One identify moment. Mirrors the writer's `CampaignConversionKind`. */\nexport type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number]\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a one-to-one email a rep's sequence sent: the\n * touch names the CAMPAIGN the sequence is in, like an email touch names\n * the campaign whose mail was clicked, plus the sequence and the enrollment\n * it came through, so a record can say which rep's outreach it credits.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence' | 'page'\n\n/**\n * THE RULE an identify moment is credited under (AGL-3461), stamped on every\n * record as `model` so a reader can print what was counted.\n *\n * `last-click` — the revenue join's rule, which an order still uses — named\n * the later of a campaign email click and a labeled link. A page filed under a\n * campaign is a touch too now, and it is not a click, so the identify moment\n * says so:\n *\n * - the later of a campaign email click and a link carrying `utm_` labels\n * wins, exactly as before;\n * - a page filed under a campaign competes with that winner. Within\n * {@link CAMPAIGN_VISIT_MINUTES} of it the two are one visit: a click that\n * names a campaign — its mail, or a label the campaign declares — keeps\n * the credit for the page it landed on, and a label that names none\n * yields to the page. Further apart, the later one wins;\n * - all of it inside the seven-day window, as before.\n */\nexport const CAMPAIGN_TOUCH_MODEL = 'last-touch'\n\n/** How close a page view and a click are to be one visit, in minutes. */\nexport const CAMPAIGN_VISIT_MINUTES = 30\n\n/** The same, in milliseconds. */\nexport const CAMPAIGN_VISIT_MS = CAMPAIGN_VISIT_MINUTES * 60 * 1000\n\n/**\n * The rule a record or a rollup was credited under, as one sentence a reader\n * can check the figure against. Keyed off the STORED model, so a conversion\n * credited under an older rule prints the rule it was credited under.\n */\nexport function campaignCreditRule(\n model: string | null | undefined,\n windowDays: number | null | undefined,\n): string {\n const days = Number(windowDays ?? 0) || EMAIL_ATTRIBUTION_WINDOW_DAYS\n if (model === CAMPAIGN_TOUCH_MODEL) {\n return (\n `Credited to the visitor’s latest campaign touch within ${days} days — a ` +\n 'campaign email they clicked, a link labeled for a campaign, or a page ' +\n `filed under one. A page viewed within ${CAMPAIGN_VISIT_MINUTES} minutes of ` +\n 'a click is that click’s visit, and the click keeps the credit when it ' +\n 'names a campaign.'\n )\n }\n if (model === EMAIL_ATTRIBUTION_MODEL) {\n return `Credited to the last campaign whose link they clicked, within ${days} days of that click.`\n }\n return `Credited under the ${String(model ?? 'recorded')} model, within ${days} days.`\n}\n\n/**\n * The record each conversion kind credits, as its owner publishes the kind\n * (`plugin-record-counts`, `plugin-record-lists`): a credited form names the\n * SUBMISSION it credits, the rest the record of their own name. The owners\n * count and read those records; this plugin only names the kind.\n */\nexport const CAMPAIGN_CONVERSION_RECORD_KINDS: Readonly<\n Record<CampaignConversionKind, string>\n> = {\n form: 'formSubmission',\n lead: 'lead',\n contact: 'contact',\n booking: 'booking',\n}\n\n/**\n * What a reader calls one kind, and what the count means.\n *\n * The note is the half that stops the addition. Each one names a DIFFERENT\n * population of the same visitors, and saying so under every figure is what\n * makes four numbers standing apart read as deliberate rather than as a\n * missing total.\n */\nexport interface CampaignConversionKindCopy {\n label: string\n note: string\n}\n\nexport const CAMPAIGN_CONVERSION_KIND_COPY: Readonly<\n Record<CampaignConversionKind, CampaignConversionKindCopy>\n> = {\n form: {\n label: 'Form submissions',\n note: 'submissions credited to this campaign',\n },\n lead: { label: 'Leads', note: 'new leads credited to this campaign' },\n contact: {\n label: 'Contacts',\n note: 'new contacts credited to this campaign',\n },\n booking: { label: 'Bookings', note: 'bookings credited to this campaign' },\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/conversions`.\n *\n * Its own document beside `reports/revenue`, for the reason that one is: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with what the campaign caused would enlarge\n * every one of those reads.\n */\nexport interface CampaignConversionsRollup {\n byKind?: Partial<Record<CampaignConversionKind, number>>\n /** The model these conversions were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * The stored shape of `hosts/{hostId}/campaignAttributions/{kind}:{refId}`.\n *\n * Read-side only, and every field optional: this is a document somebody\n * else's writer produced, and a reader that assumed a field was present would\n * throw on the first record written by an older version of it.\n */\nexport interface CampaignConversionRecord {\n kind?: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId?: string\n channel?: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or,\n * for a `sequence` touch, the campaign container the sequence is in.\n */\n campaignId?: string\n /** The sequence, when the touch was a sequence email (AGL-3254). */\n sequenceId?: string\n /** The enrollment the sequence email went out under (AGL-3254). */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs?: number\n /** When the visitor became identifiable, epoch ms. */\n convertedAtMs?: number\n /** The screen the visitor viewed, for a `page` touch (AGL-3461). */\n screenId?: string\n /** That page's path, for a `page` touch. */\n path?: string\n model?: string\n windowDays?: number\n}\n\n/**\n * The document id for one conversion — `{kind}:{refId}`.\n *\n * The reader's half of the writer's `campaignConversionId`. It is restated\n * here rather than imported because the writer lives in `tenant-data-admin`,\n * which a UI library may not import, and it is one line whose shape is\n * asserted in this module's spec.\n *\n * Answers `null` for an unusable pair rather than building `form:undefined`,\n * which would be a valid document path pointing at a record that can never\n * exist — a keyed read that silently reports \"not attributed\" for every\n * record on the screen.\n */\nexport function campaignConversionId(\n kind: string | null | undefined,\n refId: string | null | undefined,\n): string | null {\n const k = String(kind ?? '')\n const ref = String(refId ?? '')\n if (!k || !ref) return null\n if (!(CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(k)) return null\n // A slash would leave the collection; the id scheme already spends the\n // colon, and a ref carrying one would make the pair ambiguous.\n if (ref.includes('/') || ref.includes(':')) return null\n return `${k}:${ref}`\n}\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/** One kind's figure on screen. Independent of every other kind's. */\nexport interface CampaignConversionKindReport {\n kind: CampaignConversionKind\n label: string\n /**\n * Conversions of this kind, or `null` when the rollup holds no entry.\n *\n * `null` rather than 0 for {@link CampaignConversionsReport.recorded}'s\n * reason, applied per kind: a site with no booking form has never written a\n * booking conversion, and rendering that as a measured zero invites the\n * reader to conclude the campaign failed at something it never attempted.\n */\n value: number | null\n note: string\n}\n\n/** Everything the conversions section renders. */\nexport interface CampaignConversionsReport {\n /**\n * One entry per kind, always all four and always in\n * {@link CAMPAIGN_CONVERSION_KINDS} order.\n *\n * There is deliberately NO total beside this array, and no field anywhere\n * on this report that holds one. See the module docblock.\n */\n kinds: CampaignConversionKindReport[]\n /**\n * Whether the rollup document exists at all.\n *\n * `false` is NOT \"this campaign caused nothing\" — it is also every campaign\n * sent before the join existed, and every campaign whose conversions all\n * arrived through the web channel, which writes no rollup. The screen\n * renders the difference rather than printing a zero for all three.\n */\n recorded: boolean\n /** At least one kind holds a figure. */\n any: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns the stored rollup into the conversions section.\n *\n * Takes the rollup and nothing else. Unlike the revenue report there is no\n * denominator to hand in: a conversion RATE over delivered messages would be\n * the same defect the revenue section refuses for orders — one visitor can\n * submit two forms, so the quotient passes 100% without anything being wrong\n * — and counting distinct people would need a document per person per\n * campaign, the per-recipient read this whole surface exists to refuse.\n */\nexport function campaignConversionsReport(options: {\n rollup: CampaignConversionsRollup | undefined\n}): CampaignConversionsReport {\n const { rollup } = options\n const stored = rollup?.byKind ?? {}\n const caveats: CampaignCaveat[] = []\n\n const kinds: CampaignConversionKindReport[] = CAMPAIGN_CONVERSION_KINDS.map(\n (kind) => {\n const raw = stored[kind]\n const copy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n return {\n kind,\n label: copy.label,\n // `undefined` means the rollup has no entry for this kind, which is\n // not a measured zero. A stored 0 is impossible — the writer only\n // ever increments — but it is read as unrecorded for the same reason.\n value: raw === undefined || count(raw) === 0 ? null : count(raw),\n note: copy.note,\n }\n },\n )\n\n const any = kinds.some((entry) => entry.value !== null)\n\n if (any) {\n caveats.push({\n id: 'conversions-kinds-overlap',\n message:\n 'These figures count different things about the same visits and are ' +\n 'deliberately not added together. One person filling in one form can ' +\n 'appear as a submission, a contact and a lead, so a total would ' +\n 'count that visit three times.',\n })\n caveats.push({\n id: 'conversions-web-not-rolled-up',\n message:\n 'This email only. A conversion credited to a page filed under the ' +\n 'campaign, or to a link labeled for it, is counted on the ' +\n 'campaign’s own page; one credited to a utm_ label no campaign ' +\n 'declares is listed under Conversions in the marketing console.',\n })\n }\n\n return {\n kinds,\n recorded: rollup !== undefined,\n any,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/**\n * How much of one kind was credited to anything at all.\n *\n * ## Why this figure has to exist\n *\n * The join writes nothing for a conversion it cannot credit, so a list of\n * attribution records is a list of the SUCCESSES. Rendering only that turns\n * \"we credited four of these\" into \"four of these happened\", and the reader\n * draws a conclusion about their campaigns from a number that is mostly a\n * fact about how many visitors arrived without a campaign link.\n *\n * So the screen shows both halves and this computes the second. `attributed`\n * is counted over the attribution records; `total` over the records the\n * conversions themselves live in.\n *\n * ## Why the difference is a CEILING and says so\n *\n * The two counts come from different collections with different histories,\n * and the gap between them holds three things that are not the same:\n *\n * - conversions by visitors who arrived directly, which is what the figure\n * is meant to describe;\n * - conversions from before the join existed, which were never eligible; and\n * - for contacts, conversions on the org's OTHER sites, because contacts are\n * shared across an org while attributions are per host.\n *\n * None of those can be separated from the others without a field nobody\n * writes, so `unattributed` is reported as an upper bound and\n * {@link CampaignConversionsCoverage.exact} is `false` whenever the reasons\n * apply. A figure presented as exact when it is not is the failure this\n * whole surface is built to avoid.\n */\nexport interface CampaignConversionsCoverage {\n kind: CampaignConversionKind\n /** Conversions of this kind credited to some campaign or label. */\n attributed: number\n /** Conversions of this kind that exist, credited or not. */\n total: number\n /** `total - attributed`, clamped at zero. An upper bound, not a count. */\n unattributed: number\n /**\n * Whether `unattributed` may be read as \"arrived directly\".\n *\n * Always `false` today — the three reasons in the docblock all apply to\n * every host — and kept as a field rather than hardcoded in the copy so a\n * screen asks the model rather than restating its conclusion.\n */\n exact: boolean\n caveats: CampaignCaveat[]\n}\n\n/**\n * The attributed/unattributed split for one kind.\n *\n * `total` is `null` when it could not be counted — the aggregation failed, or\n * the collection is not readable from this surface — and the split is then\n * withheld entirely rather than defaulting `total` to `attributed`, which\n * would render every conversion as attributed and is the single most\n * flattering wrong answer available here.\n */\nexport function campaignConversionsCoverage(options: {\n kind: CampaignConversionKind\n attributed: number | null | undefined\n total: number | null | undefined\n /** The kind's records live outside this host, so `total` over-counts. */\n crossHostTotal?: boolean\n}): CampaignConversionsCoverage | null {\n const { kind, crossHostTotal } = options\n if (options.total == null || options.attributed == null) return null\n const attributed = count(options.attributed)\n const total = count(options.total)\n const caveats: CampaignCaveat[] = []\n\n /*\n * CLAMPED, and only here at the point of display. The two counts are taken\n * from two collections a moment apart, so a conversion recorded between\n * them makes `attributed` briefly exceed `total`; a negative count of\n * things that did not happen is not a sentence anybody can act on.\n */\n const unattributed = Math.max(0, total - attributed)\n\n caveats.push({\n id: 'conversions-unattributed-is-a-ceiling',\n message:\n `${unattributed.toLocaleString()} of these are not credited to any ` +\n 'campaign. Most arrived without following a campaign link and are ' +\n 'recorded as direct — nothing is guessed from a referrer, and no ' +\n 'campaign is credited for being the most recent one to run. The rest ' +\n 'are records from before campaign attribution was recorded at all, so ' +\n 'read this as an upper bound rather than a count of direct arrivals.',\n })\n if (crossHostTotal) {\n caveats.push({\n id: 'conversions-total-crosses-hosts',\n message:\n 'Contacts are shared across every site in this organization, while ' +\n 'attributions belong to one site. The uncredited figure therefore ' +\n 'includes contacts that were created on another site and could ' +\n 'never have been credited here.',\n })\n }\n\n return {\n kind,\n attributed,\n total,\n unattributed,\n // Never true while the reasons above stand. The field exists so a screen\n // reads the model rather than restating its conclusion in JSX.\n exact: false,\n caveats,\n }\n}\n\n/**\n * How a record names the thing it was credited to.\n *\n * The email channel names a campaign document, so the screen can link to it,\n * and so does a sequence touch: the campaign the sequence is in is a\n * container with a page — as does a page touch, the campaign the viewed page\n * is filed under (AGL-3461). The web channel names a label the marketer\n * typed, which is text unless a campaign declares the label as its own; then\n * the record carries that campaign's id too and the screen links it.\n *\n * The `utm_` triple is joined in the order a marketer set it, with the parts\n * that are absent left out rather than filled with a placeholder.\n * `google / cpc` and `google / cpc / (none)` describe the same link, and only\n * one of them invites the reader to look for a campaign called \"(none)\".\n */\nexport function campaignTouchLabel(\n record: CampaignConversionRecord | null | undefined,\n): string {\n if (!record) return ''\n if (\n record.channel === 'email' ||\n record.channel === 'sequence' ||\n record.channel === 'page'\n ) {\n return String(record.campaignId ?? '')\n }\n const parts = [record.source, record.medium, record.campaign]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n return parts.join(' / ')\n}\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_TOUCH_MODEL","CAMPAIGN_VISIT_MINUTES","CAMPAIGN_VISIT_MS","campaignCreditRule","model","windowDays","days","Number","String","CAMPAIGN_CONVERSION_RECORD_KINDS","form","lead","contact","booking","CAMPAIGN_CONVERSION_KIND_COPY","label","note","campaignConversionId","kind","refId","k","ref","includes","count","raw","value","Math","floor","isFinite","campaignConversionsReport","options","rollup","stored","byKind","caveats","kinds","map","copy","undefined","any","some","entry","push","id","message","recorded","campaignConversionsCoverage","crossHostTotal","total","attributed","unattributed","max","toLocaleString","exact","campaignTouchLabel","record","channel","campaignId","parts","source","medium","campaign","part","trim","filter","Boolean","join"],"mappings":"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"}
@@ -0,0 +1,74 @@
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
+ * WHO A CAMPAIGN REACHED ON THE SITE (AGL-3461) — first visits and views of
19
+ * the pages filed under it.
20
+ *
21
+ * ## One document per campaign, keyed by the campaign's id
22
+ *
23
+ * `orgs/{orgId}/campaignVisitReports/{campaignId}`, beside the sequence
24
+ * rollup and on the same footing: counts, never a person, written only on the
25
+ * Admin SDK by the site collector's `campaignVisit` beacon, readable by
26
+ * whoever may open the campaign. Keyed by the campaign DOCUMENT, so the
27
+ * collection is bounded by the campaigns that exist — never by a label or a
28
+ * path a visitor's URL could mint.
29
+ *
30
+ * ## What each figure counts
31
+ *
32
+ * - **Page views** — views of the pages filed under the campaign, one per
33
+ * pageview, whoever the visitor is. It needs nothing from the visitor's
34
+ * device, so every view counts.
35
+ * - **First visits** — visitors whose first campaign touch in the
36
+ * attribution window was this campaign: a page filed under it, or a link
37
+ * carrying a `utm_campaign` label it declares. The device answers from the
38
+ * touch it already remembers (`claimFirstCampaignTouch` in
39
+ * `utm-touch.ts`): one held from the last seven days means this visit is
40
+ * not a first. A visitor whose consent posture keeps nothing on their
41
+ * device cannot be told from the next one and is not counted at all, so
42
+ * the figure is a floor, and the page says so.
43
+ *
44
+ * Both are also kept per month, under `byMonth`, so a campaign with dates can
45
+ * be read for its own window. Twelve keys a year is the bound.
46
+ */
47
+ /** The org collection the rollups live in. */
48
+ export declare const CAMPAIGN_VISIT_REPORTS_COLLECTION = "campaignVisitReports";
49
+ /** One month's figures, under `byMonth['YYYY-MM']`. */
50
+ export interface CampaignVisitMonth {
51
+ views?: number;
52
+ firstVisits?: number;
53
+ }
54
+ /** The stored shape of `orgs/{orgId}/campaignVisitReports/{campaignId}`. */
55
+ export interface CampaignVisitRollup {
56
+ views?: number;
57
+ firstVisits?: number;
58
+ byMonth?: Record<string, CampaignVisitMonth>;
59
+ }
60
+ /** What the campaign's page draws. */
61
+ export interface CampaignVisitsReport {
62
+ /** Whether anything has been counted at all. */
63
+ recorded: boolean;
64
+ views: number;
65
+ firstVisits: number;
66
+ /** The first month anything was counted, `YYYY-MM`, or `''`. */
67
+ since: string;
68
+ }
69
+ /**
70
+ * The rollup as the page reads it. `since` is the earliest month on record,
71
+ * so the figures say how far back they reach: they start when counting
72
+ * started, not when the campaign did.
73
+ */
74
+ export declare function campaignVisitsReport(rollup: CampaignVisitRollup | null | undefined): CampaignVisitsReport;