@aglyn/plugins-marketing 1.0.0-beta.261 → 1.0.0-beta.263
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -16
- package/src/lib/components/email-detail.js +5 -4
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/model/campaign-container.d.ts +2 -1
- package/src/lib/model/campaign-container.js +13 -0
- package/src/lib/model/campaign-container.js.map +1 -1
- package/src/lib/model/email-record.d.ts +7 -0
- package/src/lib/model/email-record.js +12 -0
- package/src/lib/model/email-record.js.map +1 -1
- package/src/lib/server/marketing-figures.js +25 -0
- package/src/lib/server/marketing-figures.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-marketing",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.263",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,21 +25,21 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
40
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
41
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
42
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.263",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.263",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.263",
|
|
31
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.263",
|
|
32
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.263",
|
|
33
|
+
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.263",
|
|
34
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.263",
|
|
35
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.263",
|
|
36
|
+
"@aglyn/shared-util-email": "1.0.0-beta.263",
|
|
37
|
+
"@aglyn/shared-util-http": "1.0.0-beta.263",
|
|
38
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.263",
|
|
39
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.263",
|
|
40
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.263",
|
|
41
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.263",
|
|
42
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.263",
|
|
43
43
|
"@swc/helpers": "0.5.23"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
@@ -31,7 +31,7 @@ import { useCallback, useMemo, useState } from "react";
|
|
|
31
31
|
import { sendLinkReport } from "@aglyn/shared-ui-email-campaigns/model/send-report";
|
|
32
32
|
import { campaignReport } from "../model/campaign-report.js";
|
|
33
33
|
import { campaignHeldForReviewNotice, campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "../model/campaign-container.js";
|
|
34
|
-
import { emailAudienceLabel, emailIsUnsent, emailSendTimeMs, emailSentAs } from "../model/email-record.js";
|
|
34
|
+
import { emailAudienceLabel, emailIsUnsent, emailNeverSent, emailSendTimeMs, emailSentAs } from "../model/email-record.js";
|
|
35
35
|
import { emailPlainTextState } from "@aglyn/aglyn/app-utils/recipient-email-render";
|
|
36
36
|
import { useMarketingHubPath } from "./use-marketing-hub-path.js";
|
|
37
37
|
import { siteRecordRouteContext, useRecordRouteContext } from "./record-route-context.js";
|
|
@@ -189,6 +189,7 @@ const emailDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
189
189
|
* "this reached nobody" rather than "this has not been sent", and those are
|
|
190
190
|
* different facts about an email.
|
|
191
191
|
*/ const unsent = emailIsUnsent(email);
|
|
192
|
+
/** Canceled or failed before it went to anybody: it never will, and has no report either. */ const neverSent = emailNeverSent(email);
|
|
192
193
|
/** The merchant's own name for this email, where one was given. */ const displayName = String((_ref4 = email == null ? void 0 : email.displayName) != null ? _ref4 : '');
|
|
193
194
|
/**
|
|
194
195
|
* How many times this email has been sent, and when the last one was.
|
|
@@ -846,7 +847,7 @@ const emailDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
846
847
|
}) : /*#__PURE__*/ _jsx(Typography, {
|
|
847
848
|
variant: "body2",
|
|
848
849
|
color: "text.secondary",
|
|
849
|
-
children: unsent ? 'not sent yet' : 'not recorded'
|
|
850
|
+
children: unsent ? 'not sent yet' : neverSent ? 'not sent' : 'not recorded'
|
|
850
851
|
})
|
|
851
852
|
})
|
|
852
853
|
]
|
|
@@ -935,12 +936,12 @@ const emailDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
935
936
|
]
|
|
936
937
|
}),
|
|
937
938
|
/*#__PURE__*/ _jsx(Divider, {}),
|
|
938
|
-
unsent ? /*#__PURE__*/ _jsx(Section, {
|
|
939
|
+
unsent || neverSent ? /*#__PURE__*/ _jsx(Section, {
|
|
939
940
|
title: "Delivery",
|
|
940
941
|
children: /*#__PURE__*/ _jsx(Typography, {
|
|
941
942
|
variant: "body2",
|
|
942
943
|
color: "text.secondary",
|
|
943
|
-
children: sending ? 'This email is being sent right now. Its figures appear ' + 'here once the send finishes.' : draft ? 'This email has not been sent, so there is nothing to ' + 'report yet. Write this email — the link is in the ' + 'header — then send it or put it on the schedule.' : 'This email has not been sent yet. Its figures appear ' + 'here once it goes out.'
|
|
944
|
+
children: neverSent ? 'This email was not sent to anybody, so there is ' + 'nothing to report.' : sending ? 'This email is being sent right now. Its figures appear ' + 'here once the send finishes.' : draft ? 'This email has not been sent, so there is nothing to ' + 'report yet. Write this email — the link is in the ' + 'header — then send it or put it on the schedule.' : 'This email has not been sent yet. Its figures appear ' + 'here once it goes out.'
|
|
944
945
|
})
|
|
945
946
|
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
946
947
|
children: [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/email-detail.tsx"],"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'use client'\n\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiCalendarClockOutline,\n mdiCloseCircleOutline,\n mdiDeleteOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n sendLinkReport,\n type SendLinkRollup,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { campaignReport } from '../model/campaign-report'\nimport {\n campaignHeldForReviewNotice,\n campaignSendDisplay,\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '../model/campaign-container'\nimport {\n emailAudienceLabel,\n emailIsUnsent,\n emailSendTimeMs,\n emailSentAs,\n} from '../model/email-record'\nimport { emailPlainTextState } from '@aglyn/aglyn/app-utils/recipient-email-render'\nimport { useMarketingHubPath } from './use-marketing-hub-path'\nimport { siteRecordRouteContext, useRecordRouteContext } from './record-route-context'\nimport { campaignSendDoc, campaignSendReportDoc } from './campaign-queries'\nimport { useMarketingOrgId, useMarketingOrgMount } from './marketing-org-mount'\nimport { CampaignDesignPreview as EmailDesignPreview } from './campaign-email-zones'\nimport EmailEditDrawer from './email-edit-drawer'\nimport EmailRecipientsCard from './email-recipients-card'\nimport {\n Figure,\n percent,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n useCampaignManageApi,\n useCampaignSendApi,\n} from './use-campaign-send-api'\n\nconst previewDocsHelp = pluginDocsHelp('emailCampaigns', { anchor: '#email-preview' })\n\nconst emailDocsHelp = pluginDocsHelp('emailCampaigns', { anchor: '#email-page' })\n\nexport interface EmailDetailProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The message document under `orgs/{orgId}/campaigns`. */\n emailId: string\n /**\n * The Emails page's own URL, under the site or the organization, for the\n * way back and for sibling links.\n */\n basePath: string\n}\n\n/**\n * ONE MESSAGE: what it looked like, where it went, and what it did.\n *\n * ## The template it was built from, drawn as it stands NOW\n *\n * The preview renders the template's CURRENT version, because the HTML that\n * was actually mailed is not stored — it is rendered per recipient at send\n * time, with that recipient's merge values in it, and keeping a copy per\n * message would be a copy of the whole email per address. So a message sent\n * before its template was last edited previews as the template is today, and\n * the frame says so rather than letting a reader take it for a record of what\n * went out.\n *\n * ## Every rate names its denominator\n *\n * The arithmetic is `campaign-report.ts` — the same pure module the campaign\n * report reads — so open rate over `delivered` and click rate over `delivered`\n * are computed once, carry their own denominator labels, and come back `null`\n * rather than 0% when they cannot honestly be taken. Nothing on this screen\n * divides anything.\n *\n * ## What this page reads\n *\n * Four documents: the message, its link rollup, the template screen and the\n * template's version. None of them grows with the size of the send. The\n * recipient list is the one read that does, and it is its own card with its\n * own request.\n *\n * ## The site it is sent as\n *\n * The message is the org's, but its template, its sender, its recipients and\n * every action on it belong to the one site it is sent as. Under a site that\n * is the site. On the org hub it is the send's own `hostId` — and a send\n * written before sends named their site cannot be acted on from there, so it\n * says so instead of guessing a site.\n */\nexport function EmailDetail(props: EmailDetailProps) {\n const { emailId, basePath } = props\n // The sibling hub: a campaign's page belongs to the Marketing console.\n const marketingHub = useMarketingHubPath()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(props.hostId)\n const firestore = useFirestore()\n\n const { data: email, status } = useFirestoreDoc<\n Record<string, any> & { stats?: SendStats }\n >(\n () => (orgId ? campaignSendDoc(firestore, orgId, emailId) : null),\n [firestore, orgId, emailId],\n )\n const notFound = status !== 'loading' && !email\n /**\n * The site this email is sent as — every site-scoped read and action below\n * is that site's. `null` on the org hub for a send that names none.\n */\n const hostId: string | null =\n props.hostId ?? (email?.hostId ? String(email.hostId) : null)\n /** On the org hub, a send with no site: readable, but nothing to act as. */\n const siteless = !props.hostId && Boolean(email) && !hostId\n /*\n * The message pages' own addresses, beneath the Emails page this one is on\n * — the site's or the organization's. A template is always the sending\n * site's design, so over the org it opens on that site, at the address the\n * Emails page publishes for it.\n */\n const messagesPath = `${basePath}/messages`\n const templateContext = siteRecordRouteContext(useRecordRouteContext(), orgMount, hostId)\n\n /*\n * The link rollup, its own document rather than a field on the message.\n *\n * A map of destinations grows with the content, and the message document is\n * read by the list, the glance widget and the send path; putting an\n * unbounded map on it would make every one of those reads larger.\n */\n const { data: links } = useFirestoreDoc<SendLinkRollup>(\n () =>\n orgId ? campaignSendReportDoc(firestore, orgId, emailId, 'links') : null,\n [firestore, orgId, emailId],\n )\n\n const templateScreenId: string | undefined = email?.templateScreenId\n const templateHrefOf = (id: string | undefined): string | null =>\n id && templateContext ? pluginRecordHref('emailTemplate', templateContext, id) : null\n const templateHref = templateHrefOf(templateScreenId)\n const { data: template } = useFirestoreDoc<any>(\n () =>\n templateScreenId && hostId\n ? doc(firestore, 'hosts', hostId, 'screens', templateScreenId)\n : null,\n [firestore, hostId, templateScreenId],\n )\n const templateVersionId: string | undefined = template?.versionId\n const { data: templateVersion } = useFirestoreDoc<any>(\n () =>\n templateScreenId && templateVersionId && hostId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'screens',\n templateScreenId,\n 'versions',\n templateVersionId,\n )\n : null,\n [firestore, hostId, templateScreenId, templateVersionId],\n )\n\n /**\n * A hand-written plain-text version that no longer describes the design.\n *\n * Said HERE and not only in the composer, because an email can be scheduled\n * and then have its design edited — after which nobody opens the composer\n * again, and the send goes out with a styled half and a text half that\n * disagree. This page is where somebody looks at a scheduled email, so it is\n * where the fact has to be readable.\n *\n * Only while the email is unsent. On a sent one the text part that went out\n * is history; the design moving afterwards is expected and is what the\n * preview's own note already says.\n */\n const plainTextState = emailPlainTextState(\n {\n plainText: String(email?.plainText ?? ''),\n plainTextVersionId: String(email?.plainTextVersionId ?? ''),\n },\n template?.versionId,\n )\n\n const report = useMemo(() => campaignReport(email?.stats), [email])\n const linkReport = useMemo(() => sendLinkReport(links), [links])\n const subject = String(email?.subject || 'Untitled email')\n /*\n * The composed body, kept on the send document. A message written without\n * a template still has a rendered HTML part in the inbox, so this is what\n * the preview draws for one.\n */\n const composedBody = String(email?.body ?? '')\n const sendTimeMs = email ? emailSendTimeMs(email) : 0\n const state = String(email?.status ?? '')\n /** What this email is doing, which the stored status alone cannot say. */\n const display = campaignSendDisplay(email as never)\n /**\n * Part way through an audience larger than one batch.\n *\n * Stored as `scheduled` — the state the processor claims to resume it — so\n * every control below that keyed on `scheduled` alone was offering an\n * action about an email that is already going out.\n */\n const midFlight = display.state === 'sending'\n /**\n * Held for staff review by the phishing screen (AGL-3356). Stored as\n * `scheduled`, so every control below that offers to send, reschedule or\n * edit a scheduled email is withheld on it: none of them is the merchant's\n * to use until staff decide. Canceling it stays — withdrawing an email is\n * always theirs.\n */\n const held = display.state === 'held'\n /**\n * This email has not gone to anybody yet.\n *\n * Everything below the state table is a REPORT, and an unsent email has\n * nothing to report — no `stats` at all. Drawing the figures anyway would\n * fill the page with zeros and a delivery rate of 0%, which is the reading\n * \"this reached nobody\" rather than \"this has not been sent\", and those are\n * different facts about an email.\n */\n const unsent = emailIsUnsent(email)\n /** The merchant's own name for this email, where one was given. */\n const displayName = String(email?.displayName ?? '')\n /**\n * How many times this email has been sent, and when the last one was.\n *\n * A message written before an email could be sent twice carries neither, and\n * one send is what an absent count means — not zero.\n */\n const sendCount = Number(email?.sendCount ?? 1) || 1\n const lastSentMs = email?.lastSentAt\n ? emailSendTimeMs({ sentAt: email.lastSentAt })\n : 0\n /**\n * The sender this message actually left with, as the SEND recorded it.\n *\n * Read, never composed. The site's sending identity is a setting: a\n * merchant who verifies a new domain in November has not changed what went\n * out in March, and resolving the identity here would answer \"what would\n * this send as today\" on a page whose whole subject is a message that\n * already went. Exactly the rule the list name beside it follows.\n */\n const sentAs = emailSentAs(email)\n\n /*\n * The campaign this message belongs to.\n *\n * `emailCampaignId` — {@link CAMPAIGN_SEND_CONTAINER_FIELD} — is the one\n * linkage, and it is deliberately not spelled `campaignId`: on a message\n * document that name already means the message's OWN id, which is what the\n * report route addresses and what every delivered unsubscribe footer\n * carries as `cid=`.\n *\n * The fallback is the migration. A message written before campaigns grouped\n * anything names no container, and its own id IS the campaign the URL\n * resolves — the campaign detail route answers an id it does not recognize\n * as a container with that message's own report.\n */\n const campaignId = String(email?.[CAMPAIGN_SEND_CONTAINER_FIELD] ?? emailId)\n\n /*==========================================\n * SENDING THIS EMAIL TO MORE PEOPLE.\n *\n * The whole control is a confirmation and one POST. Every decision it looks\n * like it is making — who is left, who is suppressed, whether there is\n * allowance and hourly room — is made by the send path, which is also the\n * path the original send took; asking any of it here would be a second set\n * of rules to disagree with the first.\n *\n * Two requests rather than one, and the first is a READ. `dryRun` runs the\n * whole resolution and writes nothing, so the confirmation can say how many\n * people this would reach before the merchant agrees to it. A send is the\n * one action on this page that cannot be taken back, and \"Send to more\n * recipients?\" with no number in it is a button nobody can answer honestly.\n *=========================================*/\n const campaignSendApi = useCampaignSendApi(hostId)\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n const [sendingMore, setSendingMore] = useState(false)\n\n const handleSendToMore = useCallback(async () => {\n if (sendingMore) return\n setSendingMore(true)\n try {\n const counted = await campaignSendApi({\n action: 'followUp',\n campaignId: emailId,\n dryRun: true,\n })\n if (!counted.response.ok) {\n return void enqueueSnackbar(\n counted.payload?.error ?? 'This email cannot be sent again',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const reaching = Number(counted.payload?.sendable ?? 0)\n const already = Number(counted.payload?.alreadyReached ?? 0)\n if (!reaching) {\n return void enqueueSnackbar(\n 'Everyone in this audience already has this email',\n { variant: 'info', persist: false },\n )\n }\n const agreed = await confirm({\n title: 'Send this email to more people?',\n description:\n `This sends the same email to ${reaching.toLocaleString()} more ` +\n `${reaching === 1 ? 'person' : 'people'} in the same audience. ` +\n `The ${already.toLocaleString()} who already received it are not ` +\n 'sent it again, and its report adds the new figures to the ones ' +\n 'it already holds.',\n confirmationText: 'Send',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n const result = await campaignSendApi({\n action: 'followUp',\n campaignId: emailId,\n })\n if (!result.response.ok) {\n return void enqueueSnackbar(result.payload?.error ?? 'Send failed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar(\n `Sent to ${Number(result.payload?.sent ?? 0).toLocaleString()} more ` +\n 'recipients',\n { variant: 'success', persist: false },\n )\n } catch (error) {\n console.error(error)\n enqueueSnackbar('Send failed', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSendingMore(false)\n }\n }, [campaignSendApi, confirm, emailId, enqueueSnackbar, sendingMore])\n\n /*==========================================\n * THE LIFECYCLE ACTIONS.\n *\n * Every one of them is one POST to the same route the composer and the\n * scheduled processor use. None of them decides anything: whether an email\n * may be sent now, rescheduled or canceled is decided by the route against\n * the record's stored `status`, so the rules live in one place and the\n * header's job is only to offer the ones that apply.\n *\n * The copy is deliberately NOT sent with any of them. `sendNow` reads the\n * whole message off the record — a request that could also carry a subject\n * and a body would be a way to put arbitrary copy on an existing send id\n * and mail it under that id's unsubscribe scope.\n *=========================================*/\n const [busy, setBusy] = useState('')\n const [editing, setEditing] = useState<'details' | 'schedule' | null>(null)\n\n /** One POST, one snackbar, one busy flag — the shape all four share. */\n const runAction = useCallback(\n async (\n key: string,\n request: Record<string, unknown>,\n success: (payload: any) => string,\n failure: string,\n ) => {\n if (busy) return false\n setBusy(key)\n try {\n const { response, payload } = await campaignSendApi({\n campaignId: emailId,\n ...request,\n })\n if (!response.ok) {\n enqueueSnackbar(payload?.error ?? failure, {\n variant: 'warning',\n allowDuplicate: true,\n })\n return false\n }\n enqueueSnackbar(success(payload), {\n variant: 'success',\n persist: false,\n })\n return true\n } catch (error) {\n console.error(error)\n enqueueSnackbar(failure, { variant: 'error', allowDuplicate: true })\n return false\n } finally {\n setBusy('')\n }\n },\n [busy, campaignSendApi, emailId, enqueueSnackbar],\n )\n\n const handleSendNow = useCallback(async () => {\n /*\n * Counted before it is offered, the same two-request shape the follow-up\n * uses: `dryRun` runs the whole resolution and writes nothing, so the\n * confirmation can name how many people this reaches. \"Send this now?\"\n * with no number in it is a question nobody can answer honestly, and this\n * is the action on the page that cannot be taken back.\n */\n if (busy) return\n setBusy('sendNow')\n /*\n * `null` for \"the count did not happen\", which is not the same answer as\n * zero — zero is a real reach that the confirmation would go on to\n * describe, and the failure branches below return rather than reaching it.\n */\n let reaching: number | null = null\n try {\n const counted = await campaignSendApi({\n action: 'sendNow',\n campaignId: emailId,\n dryRun: true,\n })\n if (counted.response.ok) {\n reaching = Number(\n counted.payload?.sendable ?? counted.payload?.sent ?? 0,\n )\n } else {\n enqueueSnackbar(counted.payload?.error ?? 'This email cannot be sent', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n } catch (error) {\n console.error(error)\n enqueueSnackbar('Send failed', { variant: 'error', allowDuplicate: true })\n }\n setBusy('')\n if (reaching === null) return\n const agreed = await confirm({\n title: 'Send this email now?',\n description:\n `This sends it to ${reaching.toLocaleString()} ` +\n `${reaching === 1 ? 'person' : 'people'} straight away` +\n (state === 'scheduled'\n ? ', instead of at the time it is scheduled for. '\n : '. ') +\n 'It cannot be taken back once it goes.',\n confirmationText: 'Send now',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n await runAction(\n 'sendNow',\n { action: 'sendNow' },\n (payload) =>\n `Sent to ${Number(payload?.sent ?? 0).toLocaleString()} recipients`,\n 'Send failed',\n )\n }, [\n busy,\n campaignSendApi,\n confirm,\n emailId,\n enqueueSnackbar,\n runAction,\n state,\n ])\n\n /*==========================================\n * STOPPING A SEND, WHICH NOW MEANS TWO DIFFERENT THINGS.\n *\n * `cancel` acts on `scheduled`, and an email delivering an audience larger\n * than one batch is stored as `scheduled` between runs — so the control\n * that withdraws a campaign before it goes also stops one that is half\n * delivered, with no change to the route. That is a real capability, and a\n * merchant watching a send go wrong needs to be told which of the two they\n * are about to do: nothing has been mailed, or two thousand people already\n * have it and are keeping it.\n *=========================================*/\n const handleCancel = useCallback(async () => {\n const reached = display.progress.reached\n const left = display.progress.remaining\n const agreed = await confirm({\n title: midFlight ? 'Stop sending this email?' : 'Cancel this scheduled email?',\n description: midFlight\n ? `It has reached ${reached.toLocaleString()} ` +\n `${reached === 1 ? 'person' : 'people'} so far, and stopping it ` +\n `leaves ${left.toLocaleString()} unaddressed. What has already ` +\n 'gone out cannot be taken back — those messages stay in inboxes ' +\n 'and keep their unsubscribe links. The email and its report are ' +\n 'kept, but a stopped send cannot be resumed; reaching the rest ' +\n 'means composing a new email.'\n : 'It will not be sent at the time it is scheduled for. The email ' +\n 'and everything written on it are kept, but a canceled email ' +\n 'cannot be put back on the schedule — you would compose a new one.',\n confirmationText: midFlight ? 'Stop sending' : 'Cancel send',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n await runAction(\n 'cancel',\n { action: 'cancel' },\n () =>\n midFlight\n ? 'This email has stopped sending'\n : 'This email will not be sent',\n 'This email could not be canceled',\n )\n }, [confirm, display.progress, midFlight, runAction])\n\n const handleReschedule = useCallback(\n async (values: { sendAtMs?: number }) => {\n const done = await runAction(\n 'schedule',\n { action: 'schedule', sendAtMs: values.sendAtMs },\n () =>\n `Scheduled for ${new Date(\n Number(values.sendAtMs ?? 0),\n ).toLocaleString()}`,\n 'This email could not be scheduled',\n )\n if (done) setEditing(null)\n },\n [runAction],\n )\n\n /*==========================================\n * DISCARDING A DRAFT, WHICH IS THE ONE REMOVAL THIS PAGE HAS.\n *\n * Only ever offered on a `draft`, and refused again by the route inside the\n * transaction that deletes — the state on screen is a snapshot, and\n * `sendNow` claims a draft by moving it to `sending` in a transaction of\n * its own, so a check made only here could remove a record the send path\n * was mailing from.\n *\n * A sent email is never discardable from anywhere. Its report is what a\n * merchant answers a complaint with, and its id is inside the HMAC of every\n * unsubscribe link it delivered; a scheduled one is withdrawn with Cancel,\n * which keeps the record and takes it off the clock.\n *\n * The reader is sent back to the list afterwards rather than left on the\n * page of a record that no longer exists — which would render the \"could\n * not be loaded\" branch and read as a failure.\n *=========================================*/\n const manageApi = useCampaignManageApi(hostId)\n const router = useRouter()\n\n const handleDiscard = useCallback(async () => {\n const agreed = await confirm({\n title: 'Discard this draft?',\n description:\n 'This email has not been sent to anybody, and discarding it removes ' +\n 'it for good — the subject, the message and everything else written ' +\n 'on it. There is no undo.',\n confirmationText: 'Discard',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n if (busy) return\n setBusy('discard')\n try {\n const { response, payload } = await manageApi({\n action: 'discardEmail',\n campaignId: emailId,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'This draft could not be discarded',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Draft discarded', { variant: 'success', persist: false })\n router.push(messagesPath)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('This draft could not be discarded', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setBusy('')\n }\n }, [busy, confirm, emailId, enqueueSnackbar, manageApi, messagesPath, router])\n\n const handleRename = useCallback(\n async (values: { displayName?: string }) => {\n const done = await runAction(\n 'update',\n { action: 'update', displayName: values.displayName },\n () => 'Name updated',\n 'The name could not be updated',\n )\n if (done) setEditing(null)\n },\n [runAction],\n )\n\n /*==========================================\n * THE HEADER, IN THREE REGISTERS.\n *\n * Navigation reads as navigation — a naked link button, because that is\n * what it is and a reader should be able to tell without clicking. The\n * PRIMARY action of the state is the one contained button, so there is\n * exactly one on the page and it is the thing a merchant came to do.\n * Everything else goes in the overflow, and the two irreversible entries in\n * there are marked `destructive` so they carry the error color rather than\n * sitting in the list looking like \"Rename\".\n *\n * `RowActionsMenu` is named for table rows and its rendering is not: it is\n * a kebab `IconButton` and a `Menu` whose items support `onClick`,\n * `destructive`, `disabled` and `disabledReason` — exactly what a card\n * header's overflow needs. Reusing it is what keeps the menu on this page\n * behaving like every other overflow menu in the console.\n *=========================================*/\n const scheduled = state === 'scheduled'\n const draft = state === 'draft'\n const sending = state === 'sending'\n\n const overflowItems: RowActionsMenuItem[] = [\n {\n key: 'rename',\n label: 'Edit details',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n onClick: () => setEditing('details'),\n },\n /*\n Rescheduling an email that is ALREADY GOING OUT is not a thing to\n offer: its remaining batches are due when the sender said, and moving\n `sendAtMs` under the processor mid-campaign changes when the rest of a\n delivery happens rather than when it starts.\n */\n ...((draft || scheduled) && !midFlight && !held\n ? [\n {\n key: 'schedule',\n label: scheduled ? 'Reschedule' : 'Schedule',\n icon: <MdiIcon path={mdiCalendarClockOutline.path} size={0.8} />,\n onClick: () => setEditing('schedule'),\n } as RowActionsMenuItem,\n ]\n : []),\n ...(scheduled && !midFlight\n ? [\n {\n key: 'cancel',\n label: 'Cancel send',\n icon: <MdiIcon path={mdiCloseCircleOutline.path} size={0.8} />,\n destructive: true,\n disabled: Boolean(busy),\n disabledReason: 'Another action on this email is still running',\n onClick: () => void handleCancel(),\n } as RowActionsMenuItem,\n ]\n : []),\n /*\n Discard is offered ONLY on a draft, and it is hidden rather than\n disabled everywhere else — the opposite of how this menu treats\n `Reschedule`, and deliberately.\n\n A disabled control tells a reader that the action exists for this\n record and is momentarily unavailable. There is no state in which a\n sent email becomes discardable, so showing the entry greyed out on one\n would be an offer this product will never honor, sitting under the\n report it is promising to destroy.\n */\n ...(draft\n ? [\n {\n key: 'discard',\n label: 'Discard draft',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: Boolean(busy),\n disabledReason: 'Another action on this email is still running',\n onClick: () => void handleDiscard(),\n } as RowActionsMenuItem,\n ]\n : []),\n ]\n\n /*\n * The one contained button, and what it is per state.\n *\n * `draft` and `scheduled` share it: the email has not gone out, so the act\n * is to make it go. A `sent` email's is the follow-up. A `canceled` one has\n * no primary act at all — it was withdrawn deliberately, and offering a way\n * to un-withdraw it would be a resurrect path this model does not have —\n * and neither does one that is mid-send.\n */\n const primaryAction = held ? null : (draft || scheduled) && !midFlight ? (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={Boolean(busy)}\n onClick={() => void handleSendNow()}\n >\n {busy === 'sendNow' ? 'Checking…' : 'Send now'}\n </Button>\n ) : midFlight ? (\n /*\n A CAMPAIGN THAT IS ALREADY GOING OUT HAS ONE ACT: STOPPING IT.\n\n \"Send now\" is withheld rather than disabled, and the reason is not\n cosmetic — `sendNow` re-resolves the WHOLE audience and mails it, with\n no subtraction of anyone already reached, so pressing it on an email\n between batches sends a second copy to every person who has had the\n first. Withholding it leaves exactly one primary action, and it is the\n one a merchant watching a send go wrong actually wants.\n */\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"error\"\n disabled={Boolean(busy)}\n onClick={() => void handleCancel()}\n >\n {'Stop sending'}\n </Button>\n ) : state === 'sent' ? (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={sendingMore}\n onClick={() => void handleSendToMore()}\n >\n {sendingMore ? 'Checking…' : 'Send to more recipients'}\n </Button>\n ) : null\n\n /**\n * WHERE THIS EMAIL IS WRITTEN, which is no longer this page.\n *\n * A page cannot both be \"what this email did\" and \"write this email\" — the\n * first is a report the reader scrolls, the second is a form with one\n * irreversible button — so the composer is its own route and this is the\n * link to it. Naked, because navigation should read as navigation; the one\n * CONTAINED button on this page stays the primary act of the state.\n *\n * Offered only while the copy can still be changed. An email part way\n * through a send is stored as `scheduled`, so `midFlight` is what keeps the\n * link off a message that is already reaching inboxes — the same distinction\n * \"Send now\" is withheld on.\n */\n const editHref = `${messagesPath}/${emailId}/edit`\n\n const headerActions = (\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={messagesPath}\n size=\"small\"\n color=\"primary\"\n >\n {'All messages'}\n </Button>\n {(draft || scheduled) && !midFlight && !held && !siteless ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={editHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Write this email'}\n </Button>\n ) : null}\n {templateHref ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={templateHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Open template'}\n </Button>\n ) : null}\n {siteless ? null : primaryAction}\n {siteless ? null : (\n <RowActionsMenu label={subject} items={overflowItems} />\n )}\n </Stack>\n )\n\n if (notFound) {\n return (\n <CardDisplay\n header={'Email'}\n help={emailDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n {/*\n * Not \"no data\". An email that cannot be read is a different\n * situation from one with no engagement, and rendering an empty\n * report for the first is how somebody comes to believe a message\n * they sent reached nobody.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This email could not be loaded. It may have been deleted.'}\n </Typography>\n </CardDisplay>\n )\n }\n\n return (\n <Stack spacing={3}>\n {/* The page heading and the trail name the message; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={email ? subject : undefined} />\n {held ? (\n <Alert severity=\"warning\">{campaignHeldForReviewNotice(email as never)}</Alert>\n ) : null}\n {siteless ? (\n <Alert severity=\"info\">\n {'This email does not record which site it was sent as, so it can ' +\n 'only be managed from that site’s own Emails page. Its figures ' +\n 'below are complete.'}\n </Alert>\n ) : null}\n <CardDisplay\n header={'Email'}\n help={emailDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {unsent && plainTextState.stale ? (\n <Alert severity=\"warning\">\n {'The design has been edited since this email’s plain-text ' +\n 'version was written, so the two halves may no longer say the ' +\n 'same thing. Nothing has overwritten what was written — open ' +\n 'this email to read it, or take the design’s text instead.'}\n </Alert>\n ) : null}\n\n <Divider />\n\n <Section title=\"Where this went\">\n <ScrollTable size=\"small\">\n <TableBody>\n {/*\n WHAT THIS EMAIL IS DOING, not the field it stores.\n\n An email delivering an audience larger than one batch is\n written back as `scheduled` between runs — the state the\n processor claims to resume it — so the stored status read\n \"Scheduled\" on a page reporting five hundred deliveries.\n */}\n <TableRow>\n <TableCell>{'State'}</TableCell>\n <TableCell align=\"right\">\n <Chip\n size=\"small\"\n color={\n display.state === 'sending'\n ? 'info'\n : display.state === 'stopped' || held\n ? 'warning'\n : undefined\n }\n label={display.label}\n />\n </TableCell>\n </TableRow>\n {midFlight ? (\n <TableRow>\n <TableCell>{'Next batch'}</TableCell>\n <TableCell align=\"right\">\n {`${display.progress.remaining.toLocaleString()} still ` +\n 'to reach, ' +\n (display.progress.nextAtMs\n ? `next run ${new Date(\n display.progress.nextAtMs,\n ).toLocaleString()}`\n : 'next run due')}\n </TableCell>\n </TableRow>\n ) : null}\n <TableRow>\n <TableCell>\n {state === 'sent' ? 'Sent' : 'Scheduled for'}\n </TableCell>\n <TableCell align=\"right\">\n {sendTimeMs\n ? new Date(sendTimeMs).toLocaleString()\n : 'not recorded'}\n </TableCell>\n </TableRow>\n {/*\n An email that has been sent more than once, said out loud.\n Every figure below covers all of them, and a reader who took\n the single `Sent` date above for the whole story would read\n the delivery numbers as one mailing's.\n */}\n {sendCount > 1 ? (\n <TableRow>\n <TableCell>{'Sends'}</TableCell>\n <TableCell align=\"right\">\n {`${sendCount.toLocaleString()}, most recently ` +\n (lastSentMs\n ? new Date(lastSentMs).toLocaleString()\n : 'not recorded')}\n </TableCell>\n </TableRow>\n ) : null}\n {displayName ? (\n <TableRow>\n <TableCell>{'Name'}</TableCell>\n <TableCell align=\"right\">{displayName}</TableCell>\n </TableRow>\n ) : null}\n {/*\n THE ADDRESS THIS MESSAGE ACTUALLY LEFT AS.\n\n Always a row. A site's sending identity can move — a domain\n verifies, a mailbox is renamed, a sender changes — so the\n question \"what did my recipients see\" has an answer only if\n the send wrote one down, and a page that omitted the row for\n the sends that did not would make an unanswerable question\n look like one nobody asked.\n\n Three states, and they are three different facts. A\n recorded address is what went out. An unsent email has no\n address yet, which is not the same as having lost one. And a\n message sent before the send began stamping its sender says\n so plainly rather than being handed today's identity, which\n would be this page inventing history.\n */}\n <TableRow>\n <TableCell>{'Sent as'}</TableCell>\n <TableCell align=\"right\">\n {sentAs.recorded ? (\n <Typography variant=\"body2\" sx={{ fontFamily: 'monospace' }}>\n {sentAs.from}\n </Typography>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {unsent ? 'not sent yet' : 'not recorded'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n {/*\n The two rows that only exist once there is a sender to\n describe. Both name what an ABSENT value means rather than\n leaving a blank: a message with no display name showed the\n address on its own, and one with no reply address takes\n replies where it was sent from. Neither is missing\n information — each is a fact the record states by omission.\n */}\n {sentAs.recorded ? (\n <TableRow>\n <TableCell>{'From name'}</TableCell>\n <TableCell align=\"right\">\n {sentAs.fromName ?? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The address on its own'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n ) : null}\n {sentAs.recorded ? (\n <TableRow>\n <TableCell>{'Reply-to'}</TableCell>\n <TableCell align=\"right\">\n {sentAs.replyTo ?? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The sending address'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n ) : null}\n <TableRow>\n <TableCell>{'Campaign'}</TableCell>\n <TableCell align=\"right\">\n {/*\n The campaign's page belongs to the Marketing console, so\n this href is built from the sibling hub rather than this\n surface's own. Plain text until that hub resolves: a\n link with no destination is worse than none.\n */}\n {marketingHub ? (\n <AppLink href={`${marketingHub}/campaigns/${campaignId}`}>\n {'Open the campaign'}\n </AppLink>\n ) : (\n 'Open the campaign'\n )}\n </TableCell>\n </TableRow>\n <TableRow>\n <TableCell>{'List'}</TableCell>\n <TableCell align=\"right\">\n {emailAudienceLabel(email)}\n </TableCell>\n </TableRow>\n {/*\n Always a row, never a hidden one. A template this email did\n not use and a template row that was not rendered look\n identical to a reader, and the second sends them looking for\n a link that was never going to be there.\n */}\n <TableRow>\n <TableCell>{'Template'}</TableCell>\n <TableCell align=\"right\">\n {templateHref ? (\n <AppLink\n href={templateHref}\n >\n {template?.displayName ?? 'Untitled template'}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Written as plain text in the composer'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n </TableBody>\n </ScrollTable>\n {/*\n * The list is named as the SEND recorded it, and saying so is\n * what stops a renamed or deleted list quietly rewriting the\n * history of a message that went out months ago.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The list and the sender are recorded as they were when this ' +\n 'email was sent, not as this site is configured now.'}\n </Typography>\n </Section>\n\n <Divider />\n\n {/*==========================================\n * AN EMAIL THAT HAS NOT BEEN SENT HAS NO REPORT.\n *\n * Not an empty one — none. Every figure below divides or counts\n * something that only exists once mail has gone out, and an unsent\n * email carries no `stats` at all, so rendering the sections would\n * publish a column of zeros and a delivery rate of 0%. That reads\n * as \"this reached nobody\", which is a claim about a send that\n * happened; the truth is that no send has happened.\n *\n * The same reasoning the rate rows already follow, one level up: a\n * rate whose denominator is unrecorded renders absent rather than\n * as 0%, and a report whose whole subject is unrecorded renders\n * absent rather than as zeros.\n *=========================================*/}\n {unsent ? (\n <Section title=\"Delivery\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {sending\n ? 'This email is being sent right now. Its figures appear ' +\n 'here once the send finishes.'\n : draft\n ? 'This email has not been sent, so there is nothing to ' +\n 'report yet. Write this email — the link is in the ' +\n 'header — then send it or put it on the schedule.'\n : 'This email has not been sent yet. Its figures appear ' +\n 'here once it goes out.'}\n </Typography>\n </Section>\n ) : (\n <>\n <Section title=\"Delivery\">\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Addressed\"\n value={report.recipients}\n note=\"after the per-send cap\"\n />\n <Figure\n label=\"Sent\"\n value={report.sent}\n note=\"accepted by the provider\"\n />\n <Figure\n label=\"Delivered\"\n value={report.delivered}\n note=\"accepted by the receiving server\"\n />\n <Figure label=\"Bounced\" value={report.bounced} note=\"of sent\" />\n <Figure\n label=\"Marked as spam\"\n value={report.complained}\n note=\"of delivered\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Engagement\">\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Opens\"\n value={report.opens}\n note=\"every open, repeats included\"\n />\n <Figure\n label=\"Readers who opened\"\n value={report.uniqueOpens}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Clicks\"\n value={report.clicks}\n note=\"every click, repeats included\"\n />\n <Figure\n label=\"Readers who clicked\"\n value={report.uniqueClicks}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Unsubscribed\"\n value={report.unsubscribes}\n note=\"through this email's link\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Rates\">\n <Stack spacing={1}>\n <RateRow label=\"Delivery rate\" rate={report.rates.delivery} />\n <RateRow label=\"Open rate\" rate={report.rates.open} />\n <RateRow label=\"Click rate\" rate={report.rates.click} />\n <RateRow\n label=\"Click-to-open rate\"\n rate={report.rates.clickToOpen}\n />\n <RateRow label=\"Bounce rate\" rate={report.rates.bounce} />\n <RateRow label=\"Complaint rate\" rate={report.rates.complaint} />\n <RateRow\n label=\"Unsubscribe rate\"\n rate={report.rates.unsubscribe}\n />\n </Stack>\n </Section>\n\n {report.populations.length ? (\n <>\n <Divider />\n <Section title=\"Who this was allowed to reach\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Measured when this email was sent, and stored as it was ' +\n 'then. These figures describe the send, not the audience ' +\n 'as it stands today.'}\n </Typography>\n <ScrollTable size=\"small\">\n <TableBody>\n {report.populations.map((population) => (\n <TableRow key={population.id}>\n <TableCell>{population.label}</TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {population.count.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`of ${population.of.toLocaleString()} ${population.ofLabel}`}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n </Section>\n </>\n ) : null}\n\n <Divider />\n\n <Section title=\"Links\">\n {linkReport.rows.length ? (\n <>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n <TableCell align=\"right\">{'Share'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {linkReport.rows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {row.clicks.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.share\n ? `${percent(row.share.value)} of ${row.share.denominator.toLocaleString()} ${row.share.denominatorLabel}`\n : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Counted by address and path — query strings are dropped, ' +\n 'so two links to the same page with different tracking ' +\n 'parameters count as one row.'}\n </Typography>\n {linkReport.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${linkReport.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived without a destination and are not in this table.'}\n </Typography>\n ) : null}\n {linkReport.overflowClicks ? (\n <Alert severity=\"info\">\n {'This email has more distinct destinations than the ' +\n `rollup keeps. ${linkReport.overflowClicks.toLocaleString()} ` +\n 'clicks landed on links past that limit and are counted ' +\n 'in the click total above but not in this table.'}\n </Alert>\n ) : null}\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.clicks\n ? 'Clicks were recorded for this email, but none of them ' +\n 'carried a destination, so there is nothing to break down ' +\n 'by link.'\n : 'No link clicks have been recorded for this email.'}\n </Typography>\n )}\n </Section>\n </>\n )}\n </Stack>\n </CardDisplay>\n\n {hostId ? <EmailRecipientsCard hostId={hostId} emailId={emailId} /> : null}\n\n {/*\n * Last, and its own card. The numbers are what a reader came for and\n * the preview is the tallest thing on the page — above them it pushes\n * every figure below the fold.\n *\n * `header` rather than `title`: `CardDisplay` has no `title` prop, so\n * one spreads through to the MUI `Card` root and lands on the DOM as a\n * hover tooltip, leaving the card with no heading at all. The gutters\n * are named for the same reason — without them the 640px frame sits\n * flush against the card's edge.\n */}\n {/* A design is the sending site's, so there is none to draw without one. */}\n {hostId ? (\n <CardDisplay\n header={'Preview'}\n help={previewDocsHelp}\n contentGutterX\n contentGutterY\n >\n {templateScreenId ? (\n <EmailDesignPreview\n hostId={hostId}\n nodes={templateVersion?.nodes}\n loading={template === undefined || templateVersion === undefined}\n subject={subject}\n preheader={String(template?.emailPreheader ?? '')}\n emptyMessage={\n 'The template this email was built from is empty or has ' +\n 'been deleted, so there is nothing to draw.'\n }\n note={\n 'The template as it stands today. The mail itself is ' +\n 'rendered per recipient at send time and not kept, so a ' +\n 'template edited since this went out previews as it is now.'\n }\n />\n ) : (\n <EmailDesignPreview\n hostId={hostId}\n nodes={undefined}\n text={composedBody}\n loading={email === undefined}\n subject={subject}\n emptyMessage={\n 'This email carries no body, so there is nothing to draw.'\n }\n note={\n 'Written as plain text in the composer. Merge tokens are ' +\n 'left standing here — the mail itself resolves them per ' +\n 'recipient at send time and is not kept.'\n }\n />\n )}\n </CardDisplay>\n ) : null}\n\n {/*\n * Editing in a DRAWER, never a form above the content. The name is the\n * one detail a sent email still owns — see the drawer's own header for\n * why the subject, body, audience and topic are not on offer once mail\n * has been delivered.\n */}\n <EmailEditDrawer\n open={editing !== null}\n onClose={() => setEditing(null)}\n field={editing === 'schedule' ? 'schedule' : 'details'}\n title={\n editing === 'schedule'\n ? scheduled\n ? 'Reschedule this email'\n : 'Schedule this email'\n : 'Edit details'\n }\n submitLabel={\n editing === 'schedule'\n ? scheduled\n ? 'Reschedule'\n : 'Schedule'\n : 'Save'\n }\n displayName={displayName}\n sendAtMs={scheduled ? sendTimeMs : 0}\n busy={Boolean(busy)}\n note={\n editing === 'schedule'\n ? 'The email goes out at this time. You can send it sooner, or ' +\n 'cancel it, from this page.'\n : unsent\n ? 'The name is for finding this email in your own lists. The ' +\n 'subject and the message are written on this email’s own ' +\n 'compose page.'\n : 'This email has been sent, so its subject, message and ' +\n 'audience describe mail that is already in inboxes and can ' +\n 'no longer be changed. Its name is yours and stays editable.'\n }\n onSubmit={(values) =>\n void (editing === 'schedule'\n ? handleReschedule(values)\n : handleRename(values))\n }\n />\n </Stack>\n )\n}\nEmailDetail.displayName = 'EmailDetail'\n\nexport default EmailDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","pluginRecordHref","mdiCalendarClockOutline","mdiCloseCircleOutline","mdiDeleteOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","ScrollTable","useSnackbar","useFirestore","useFirestoreDoc","Alert","Button","Chip","Divider","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","useRouter","useCallback","useMemo","useState","sendLinkReport","campaignReport","campaignHeldForReviewNotice","campaignSendDisplay","CAMPAIGN_SEND_CONTAINER_FIELD","emailAudienceLabel","emailIsUnsent","emailSendTimeMs","emailSentAs","emailPlainTextState","useMarketingHubPath","siteRecordRouteContext","useRecordRouteContext","campaignSendDoc","campaignSendReportDoc","useMarketingOrgId","useMarketingOrgMount","CampaignDesignPreview","EmailDesignPreview","EmailEditDrawer","EmailRecipientsCard","Figure","percent","RateRow","Section","useCampaignManageApi","useCampaignSendApi","previewDocsHelp","anchor","emailDocsHelp","EmailDetail","props","sentAs","emailId","basePath","marketingHub","orgMount","orgId","hostId","firestore","data","email","status","notFound","String","siteless","Boolean","messagesPath","templateContext","links","templateScreenId","templateHrefOf","id","templateHref","template","templateVersionId","versionId","templateVersion","plainTextState","plainText","plainTextVersionId","report","stats","linkReport","subject","composedBody","body","sendTimeMs","state","display","midFlight","held","unsent","displayName","sendCount","Number","lastSentMs","lastSentAt","sentAt","campaignId","campaignSendApi","confirm","enqueueSnackbar","sendingMore","setSendingMore","handleSendToMore","counted","result","action","dryRun","response","ok","payload","error","variant","allowDuplicate","reaching","sendable","already","alreadyReached","persist","agreed","title","description","toLocaleString","confirmationText","then","catch","sent","console","busy","setBusy","editing","setEditing","runAction","key","request","success","failure","handleSendNow","handleCancel","reached","progress","left","remaining","handleReschedule","values","done","sendAtMs","Date","manageApi","router","handleDiscard","push","handleRename","scheduled","draft","sending","overflowItems","label","icon","path","size","onClick","destructive","disabled","disabledReason","primaryAction","color","editHref","headerActions","direction","spacing","useFlexGap","sx","alignItems","flexWrap","component","componentVariant","nativeButton","href","items","header","help","contentGutterX","contentGutterY","HeaderProps","undefined","severity","caveats","map","caveat","message","stale","align","nextAtMs","recorded","fontFamily","from","fromName","replyTo","value","recipients","note","delivered","bounced","complained","opens","uniqueOpens","clicks","uniqueClicks","unsubscribes","rate","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","populations","length","population","fontWeight","count","of","ofLabel","rows","row","wordBreak","url","share","denominator","denominatorLabel","unattributedClicks","overflowClicks","nodes","loading","preheader","emailPreheader","emptyMessage","text","onClose","field","submitLabel","onSubmit"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/email-detail.tsx"],"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'use client'\n\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiCalendarClockOutline,\n mdiCloseCircleOutline,\n mdiDeleteOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n sendLinkReport,\n type SendLinkRollup,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { campaignReport } from '../model/campaign-report'\nimport {\n campaignHeldForReviewNotice,\n campaignSendDisplay,\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '../model/campaign-container'\nimport {\n emailAudienceLabel,\n emailIsUnsent,\n emailNeverSent,\n emailSendTimeMs,\n emailSentAs,\n} from '../model/email-record'\nimport { emailPlainTextState } from '@aglyn/aglyn/app-utils/recipient-email-render'\nimport { useMarketingHubPath } from './use-marketing-hub-path'\nimport { siteRecordRouteContext, useRecordRouteContext } from './record-route-context'\nimport { campaignSendDoc, campaignSendReportDoc } from './campaign-queries'\nimport { useMarketingOrgId, useMarketingOrgMount } from './marketing-org-mount'\nimport { CampaignDesignPreview as EmailDesignPreview } from './campaign-email-zones'\nimport EmailEditDrawer from './email-edit-drawer'\nimport EmailRecipientsCard from './email-recipients-card'\nimport {\n Figure,\n percent,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n useCampaignManageApi,\n useCampaignSendApi,\n} from './use-campaign-send-api'\n\nconst previewDocsHelp = pluginDocsHelp('emailCampaigns', { anchor: '#email-preview' })\n\nconst emailDocsHelp = pluginDocsHelp('emailCampaigns', { anchor: '#email-page' })\n\nexport interface EmailDetailProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The message document under `orgs/{orgId}/campaigns`. */\n emailId: string\n /**\n * The Emails page's own URL, under the site or the organization, for the\n * way back and for sibling links.\n */\n basePath: string\n}\n\n/**\n * ONE MESSAGE: what it looked like, where it went, and what it did.\n *\n * ## The template it was built from, drawn as it stands NOW\n *\n * The preview renders the template's CURRENT version, because the HTML that\n * was actually mailed is not stored — it is rendered per recipient at send\n * time, with that recipient's merge values in it, and keeping a copy per\n * message would be a copy of the whole email per address. So a message sent\n * before its template was last edited previews as the template is today, and\n * the frame says so rather than letting a reader take it for a record of what\n * went out.\n *\n * ## Every rate names its denominator\n *\n * The arithmetic is `campaign-report.ts` — the same pure module the campaign\n * report reads — so open rate over `delivered` and click rate over `delivered`\n * are computed once, carry their own denominator labels, and come back `null`\n * rather than 0% when they cannot honestly be taken. Nothing on this screen\n * divides anything.\n *\n * ## What this page reads\n *\n * Four documents: the message, its link rollup, the template screen and the\n * template's version. None of them grows with the size of the send. The\n * recipient list is the one read that does, and it is its own card with its\n * own request.\n *\n * ## The site it is sent as\n *\n * The message is the org's, but its template, its sender, its recipients and\n * every action on it belong to the one site it is sent as. Under a site that\n * is the site. On the org hub it is the send's own `hostId` — and a send\n * written before sends named their site cannot be acted on from there, so it\n * says so instead of guessing a site.\n */\nexport function EmailDetail(props: EmailDetailProps) {\n const { emailId, basePath } = props\n // The sibling hub: a campaign's page belongs to the Marketing console.\n const marketingHub = useMarketingHubPath()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(props.hostId)\n const firestore = useFirestore()\n\n const { data: email, status } = useFirestoreDoc<\n Record<string, any> & { stats?: SendStats }\n >(\n () => (orgId ? campaignSendDoc(firestore, orgId, emailId) : null),\n [firestore, orgId, emailId],\n )\n const notFound = status !== 'loading' && !email\n /**\n * The site this email is sent as — every site-scoped read and action below\n * is that site's. `null` on the org hub for a send that names none.\n */\n const hostId: string | null =\n props.hostId ?? (email?.hostId ? String(email.hostId) : null)\n /** On the org hub, a send with no site: readable, but nothing to act as. */\n const siteless = !props.hostId && Boolean(email) && !hostId\n /*\n * The message pages' own addresses, beneath the Emails page this one is on\n * — the site's or the organization's. A template is always the sending\n * site's design, so over the org it opens on that site, at the address the\n * Emails page publishes for it.\n */\n const messagesPath = `${basePath}/messages`\n const templateContext = siteRecordRouteContext(useRecordRouteContext(), orgMount, hostId)\n\n /*\n * The link rollup, its own document rather than a field on the message.\n *\n * A map of destinations grows with the content, and the message document is\n * read by the list, the glance widget and the send path; putting an\n * unbounded map on it would make every one of those reads larger.\n */\n const { data: links } = useFirestoreDoc<SendLinkRollup>(\n () =>\n orgId ? campaignSendReportDoc(firestore, orgId, emailId, 'links') : null,\n [firestore, orgId, emailId],\n )\n\n const templateScreenId: string | undefined = email?.templateScreenId\n const templateHrefOf = (id: string | undefined): string | null =>\n id && templateContext ? pluginRecordHref('emailTemplate', templateContext, id) : null\n const templateHref = templateHrefOf(templateScreenId)\n const { data: template } = useFirestoreDoc<any>(\n () =>\n templateScreenId && hostId\n ? doc(firestore, 'hosts', hostId, 'screens', templateScreenId)\n : null,\n [firestore, hostId, templateScreenId],\n )\n const templateVersionId: string | undefined = template?.versionId\n const { data: templateVersion } = useFirestoreDoc<any>(\n () =>\n templateScreenId && templateVersionId && hostId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'screens',\n templateScreenId,\n 'versions',\n templateVersionId,\n )\n : null,\n [firestore, hostId, templateScreenId, templateVersionId],\n )\n\n /**\n * A hand-written plain-text version that no longer describes the design.\n *\n * Said HERE and not only in the composer, because an email can be scheduled\n * and then have its design edited — after which nobody opens the composer\n * again, and the send goes out with a styled half and a text half that\n * disagree. This page is where somebody looks at a scheduled email, so it is\n * where the fact has to be readable.\n *\n * Only while the email is unsent. On a sent one the text part that went out\n * is history; the design moving afterwards is expected and is what the\n * preview's own note already says.\n */\n const plainTextState = emailPlainTextState(\n {\n plainText: String(email?.plainText ?? ''),\n plainTextVersionId: String(email?.plainTextVersionId ?? ''),\n },\n template?.versionId,\n )\n\n const report = useMemo(() => campaignReport(email?.stats), [email])\n const linkReport = useMemo(() => sendLinkReport(links), [links])\n const subject = String(email?.subject || 'Untitled email')\n /*\n * The composed body, kept on the send document. A message written without\n * a template still has a rendered HTML part in the inbox, so this is what\n * the preview draws for one.\n */\n const composedBody = String(email?.body ?? '')\n const sendTimeMs = email ? emailSendTimeMs(email) : 0\n const state = String(email?.status ?? '')\n /** What this email is doing, which the stored status alone cannot say. */\n const display = campaignSendDisplay(email as never)\n /**\n * Part way through an audience larger than one batch.\n *\n * Stored as `scheduled` — the state the processor claims to resume it — so\n * every control below that keyed on `scheduled` alone was offering an\n * action about an email that is already going out.\n */\n const midFlight = display.state === 'sending'\n /**\n * Held for staff review by the phishing screen (AGL-3356). Stored as\n * `scheduled`, so every control below that offers to send, reschedule or\n * edit a scheduled email is withheld on it: none of them is the merchant's\n * to use until staff decide. Canceling it stays — withdrawing an email is\n * always theirs.\n */\n const held = display.state === 'held'\n /**\n * This email has not gone to anybody yet.\n *\n * Everything below the state table is a REPORT, and an unsent email has\n * nothing to report — no `stats` at all. Drawing the figures anyway would\n * fill the page with zeros and a delivery rate of 0%, which is the reading\n * \"this reached nobody\" rather than \"this has not been sent\", and those are\n * different facts about an email.\n */\n const unsent = emailIsUnsent(email)\n /** Canceled or failed before it went to anybody: it never will, and has no report either. */\n const neverSent = emailNeverSent(email)\n /** The merchant's own name for this email, where one was given. */\n const displayName = String(email?.displayName ?? '')\n /**\n * How many times this email has been sent, and when the last one was.\n *\n * A message written before an email could be sent twice carries neither, and\n * one send is what an absent count means — not zero.\n */\n const sendCount = Number(email?.sendCount ?? 1) || 1\n const lastSentMs = email?.lastSentAt\n ? emailSendTimeMs({ sentAt: email.lastSentAt })\n : 0\n /**\n * The sender this message actually left with, as the SEND recorded it.\n *\n * Read, never composed. The site's sending identity is a setting: a\n * merchant who verifies a new domain in November has not changed what went\n * out in March, and resolving the identity here would answer \"what would\n * this send as today\" on a page whose whole subject is a message that\n * already went. Exactly the rule the list name beside it follows.\n */\n const sentAs = emailSentAs(email)\n\n /*\n * The campaign this message belongs to.\n *\n * `emailCampaignId` — {@link CAMPAIGN_SEND_CONTAINER_FIELD} — is the one\n * linkage, and it is deliberately not spelled `campaignId`: on a message\n * document that name already means the message's OWN id, which is what the\n * report route addresses and what every delivered unsubscribe footer\n * carries as `cid=`.\n *\n * The fallback is the migration. A message written before campaigns grouped\n * anything names no container, and its own id IS the campaign the URL\n * resolves — the campaign detail route answers an id it does not recognize\n * as a container with that message's own report.\n */\n const campaignId = String(email?.[CAMPAIGN_SEND_CONTAINER_FIELD] ?? emailId)\n\n /*==========================================\n * SENDING THIS EMAIL TO MORE PEOPLE.\n *\n * The whole control is a confirmation and one POST. Every decision it looks\n * like it is making — who is left, who is suppressed, whether there is\n * allowance and hourly room — is made by the send path, which is also the\n * path the original send took; asking any of it here would be a second set\n * of rules to disagree with the first.\n *\n * Two requests rather than one, and the first is a READ. `dryRun` runs the\n * whole resolution and writes nothing, so the confirmation can say how many\n * people this would reach before the merchant agrees to it. A send is the\n * one action on this page that cannot be taken back, and \"Send to more\n * recipients?\" with no number in it is a button nobody can answer honestly.\n *=========================================*/\n const campaignSendApi = useCampaignSendApi(hostId)\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n const [sendingMore, setSendingMore] = useState(false)\n\n const handleSendToMore = useCallback(async () => {\n if (sendingMore) return\n setSendingMore(true)\n try {\n const counted = await campaignSendApi({\n action: 'followUp',\n campaignId: emailId,\n dryRun: true,\n })\n if (!counted.response.ok) {\n return void enqueueSnackbar(\n counted.payload?.error ?? 'This email cannot be sent again',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const reaching = Number(counted.payload?.sendable ?? 0)\n const already = Number(counted.payload?.alreadyReached ?? 0)\n if (!reaching) {\n return void enqueueSnackbar(\n 'Everyone in this audience already has this email',\n { variant: 'info', persist: false },\n )\n }\n const agreed = await confirm({\n title: 'Send this email to more people?',\n description:\n `This sends the same email to ${reaching.toLocaleString()} more ` +\n `${reaching === 1 ? 'person' : 'people'} in the same audience. ` +\n `The ${already.toLocaleString()} who already received it are not ` +\n 'sent it again, and its report adds the new figures to the ones ' +\n 'it already holds.',\n confirmationText: 'Send',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n const result = await campaignSendApi({\n action: 'followUp',\n campaignId: emailId,\n })\n if (!result.response.ok) {\n return void enqueueSnackbar(result.payload?.error ?? 'Send failed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar(\n `Sent to ${Number(result.payload?.sent ?? 0).toLocaleString()} more ` +\n 'recipients',\n { variant: 'success', persist: false },\n )\n } catch (error) {\n console.error(error)\n enqueueSnackbar('Send failed', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSendingMore(false)\n }\n }, [campaignSendApi, confirm, emailId, enqueueSnackbar, sendingMore])\n\n /*==========================================\n * THE LIFECYCLE ACTIONS.\n *\n * Every one of them is one POST to the same route the composer and the\n * scheduled processor use. None of them decides anything: whether an email\n * may be sent now, rescheduled or canceled is decided by the route against\n * the record's stored `status`, so the rules live in one place and the\n * header's job is only to offer the ones that apply.\n *\n * The copy is deliberately NOT sent with any of them. `sendNow` reads the\n * whole message off the record — a request that could also carry a subject\n * and a body would be a way to put arbitrary copy on an existing send id\n * and mail it under that id's unsubscribe scope.\n *=========================================*/\n const [busy, setBusy] = useState('')\n const [editing, setEditing] = useState<'details' | 'schedule' | null>(null)\n\n /** One POST, one snackbar, one busy flag — the shape all four share. */\n const runAction = useCallback(\n async (\n key: string,\n request: Record<string, unknown>,\n success: (payload: any) => string,\n failure: string,\n ) => {\n if (busy) return false\n setBusy(key)\n try {\n const { response, payload } = await campaignSendApi({\n campaignId: emailId,\n ...request,\n })\n if (!response.ok) {\n enqueueSnackbar(payload?.error ?? failure, {\n variant: 'warning',\n allowDuplicate: true,\n })\n return false\n }\n enqueueSnackbar(success(payload), {\n variant: 'success',\n persist: false,\n })\n return true\n } catch (error) {\n console.error(error)\n enqueueSnackbar(failure, { variant: 'error', allowDuplicate: true })\n return false\n } finally {\n setBusy('')\n }\n },\n [busy, campaignSendApi, emailId, enqueueSnackbar],\n )\n\n const handleSendNow = useCallback(async () => {\n /*\n * Counted before it is offered, the same two-request shape the follow-up\n * uses: `dryRun` runs the whole resolution and writes nothing, so the\n * confirmation can name how many people this reaches. \"Send this now?\"\n * with no number in it is a question nobody can answer honestly, and this\n * is the action on the page that cannot be taken back.\n */\n if (busy) return\n setBusy('sendNow')\n /*\n * `null` for \"the count did not happen\", which is not the same answer as\n * zero — zero is a real reach that the confirmation would go on to\n * describe, and the failure branches below return rather than reaching it.\n */\n let reaching: number | null = null\n try {\n const counted = await campaignSendApi({\n action: 'sendNow',\n campaignId: emailId,\n dryRun: true,\n })\n if (counted.response.ok) {\n reaching = Number(\n counted.payload?.sendable ?? counted.payload?.sent ?? 0,\n )\n } else {\n enqueueSnackbar(counted.payload?.error ?? 'This email cannot be sent', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n } catch (error) {\n console.error(error)\n enqueueSnackbar('Send failed', { variant: 'error', allowDuplicate: true })\n }\n setBusy('')\n if (reaching === null) return\n const agreed = await confirm({\n title: 'Send this email now?',\n description:\n `This sends it to ${reaching.toLocaleString()} ` +\n `${reaching === 1 ? 'person' : 'people'} straight away` +\n (state === 'scheduled'\n ? ', instead of at the time it is scheduled for. '\n : '. ') +\n 'It cannot be taken back once it goes.',\n confirmationText: 'Send now',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n await runAction(\n 'sendNow',\n { action: 'sendNow' },\n (payload) =>\n `Sent to ${Number(payload?.sent ?? 0).toLocaleString()} recipients`,\n 'Send failed',\n )\n }, [\n busy,\n campaignSendApi,\n confirm,\n emailId,\n enqueueSnackbar,\n runAction,\n state,\n ])\n\n /*==========================================\n * STOPPING A SEND, WHICH NOW MEANS TWO DIFFERENT THINGS.\n *\n * `cancel` acts on `scheduled`, and an email delivering an audience larger\n * than one batch is stored as `scheduled` between runs — so the control\n * that withdraws a campaign before it goes also stops one that is half\n * delivered, with no change to the route. That is a real capability, and a\n * merchant watching a send go wrong needs to be told which of the two they\n * are about to do: nothing has been mailed, or two thousand people already\n * have it and are keeping it.\n *=========================================*/\n const handleCancel = useCallback(async () => {\n const reached = display.progress.reached\n const left = display.progress.remaining\n const agreed = await confirm({\n title: midFlight ? 'Stop sending this email?' : 'Cancel this scheduled email?',\n description: midFlight\n ? `It has reached ${reached.toLocaleString()} ` +\n `${reached === 1 ? 'person' : 'people'} so far, and stopping it ` +\n `leaves ${left.toLocaleString()} unaddressed. What has already ` +\n 'gone out cannot be taken back — those messages stay in inboxes ' +\n 'and keep their unsubscribe links. The email and its report are ' +\n 'kept, but a stopped send cannot be resumed; reaching the rest ' +\n 'means composing a new email.'\n : 'It will not be sent at the time it is scheduled for. The email ' +\n 'and everything written on it are kept, but a canceled email ' +\n 'cannot be put back on the schedule — you would compose a new one.',\n confirmationText: midFlight ? 'Stop sending' : 'Cancel send',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n await runAction(\n 'cancel',\n { action: 'cancel' },\n () =>\n midFlight\n ? 'This email has stopped sending'\n : 'This email will not be sent',\n 'This email could not be canceled',\n )\n }, [confirm, display.progress, midFlight, runAction])\n\n const handleReschedule = useCallback(\n async (values: { sendAtMs?: number }) => {\n const done = await runAction(\n 'schedule',\n { action: 'schedule', sendAtMs: values.sendAtMs },\n () =>\n `Scheduled for ${new Date(\n Number(values.sendAtMs ?? 0),\n ).toLocaleString()}`,\n 'This email could not be scheduled',\n )\n if (done) setEditing(null)\n },\n [runAction],\n )\n\n /*==========================================\n * DISCARDING A DRAFT, WHICH IS THE ONE REMOVAL THIS PAGE HAS.\n *\n * Only ever offered on a `draft`, and refused again by the route inside the\n * transaction that deletes — the state on screen is a snapshot, and\n * `sendNow` claims a draft by moving it to `sending` in a transaction of\n * its own, so a check made only here could remove a record the send path\n * was mailing from.\n *\n * A sent email is never discardable from anywhere. Its report is what a\n * merchant answers a complaint with, and its id is inside the HMAC of every\n * unsubscribe link it delivered; a scheduled one is withdrawn with Cancel,\n * which keeps the record and takes it off the clock.\n *\n * The reader is sent back to the list afterwards rather than left on the\n * page of a record that no longer exists — which would render the \"could\n * not be loaded\" branch and read as a failure.\n *=========================================*/\n const manageApi = useCampaignManageApi(hostId)\n const router = useRouter()\n\n const handleDiscard = useCallback(async () => {\n const agreed = await confirm({\n title: 'Discard this draft?',\n description:\n 'This email has not been sent to anybody, and discarding it removes ' +\n 'it for good — the subject, the message and everything else written ' +\n 'on it. There is no undo.',\n confirmationText: 'Discard',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n if (busy) return\n setBusy('discard')\n try {\n const { response, payload } = await manageApi({\n action: 'discardEmail',\n campaignId: emailId,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'This draft could not be discarded',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Draft discarded', { variant: 'success', persist: false })\n router.push(messagesPath)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('This draft could not be discarded', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setBusy('')\n }\n }, [busy, confirm, emailId, enqueueSnackbar, manageApi, messagesPath, router])\n\n const handleRename = useCallback(\n async (values: { displayName?: string }) => {\n const done = await runAction(\n 'update',\n { action: 'update', displayName: values.displayName },\n () => 'Name updated',\n 'The name could not be updated',\n )\n if (done) setEditing(null)\n },\n [runAction],\n )\n\n /*==========================================\n * THE HEADER, IN THREE REGISTERS.\n *\n * Navigation reads as navigation — a naked link button, because that is\n * what it is and a reader should be able to tell without clicking. The\n * PRIMARY action of the state is the one contained button, so there is\n * exactly one on the page and it is the thing a merchant came to do.\n * Everything else goes in the overflow, and the two irreversible entries in\n * there are marked `destructive` so they carry the error color rather than\n * sitting in the list looking like \"Rename\".\n *\n * `RowActionsMenu` is named for table rows and its rendering is not: it is\n * a kebab `IconButton` and a `Menu` whose items support `onClick`,\n * `destructive`, `disabled` and `disabledReason` — exactly what a card\n * header's overflow needs. Reusing it is what keeps the menu on this page\n * behaving like every other overflow menu in the console.\n *=========================================*/\n const scheduled = state === 'scheduled'\n const draft = state === 'draft'\n const sending = state === 'sending'\n\n const overflowItems: RowActionsMenuItem[] = [\n {\n key: 'rename',\n label: 'Edit details',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n onClick: () => setEditing('details'),\n },\n /*\n Rescheduling an email that is ALREADY GOING OUT is not a thing to\n offer: its remaining batches are due when the sender said, and moving\n `sendAtMs` under the processor mid-campaign changes when the rest of a\n delivery happens rather than when it starts.\n */\n ...((draft || scheduled) && !midFlight && !held\n ? [\n {\n key: 'schedule',\n label: scheduled ? 'Reschedule' : 'Schedule',\n icon: <MdiIcon path={mdiCalendarClockOutline.path} size={0.8} />,\n onClick: () => setEditing('schedule'),\n } as RowActionsMenuItem,\n ]\n : []),\n ...(scheduled && !midFlight\n ? [\n {\n key: 'cancel',\n label: 'Cancel send',\n icon: <MdiIcon path={mdiCloseCircleOutline.path} size={0.8} />,\n destructive: true,\n disabled: Boolean(busy),\n disabledReason: 'Another action on this email is still running',\n onClick: () => void handleCancel(),\n } as RowActionsMenuItem,\n ]\n : []),\n /*\n Discard is offered ONLY on a draft, and it is hidden rather than\n disabled everywhere else — the opposite of how this menu treats\n `Reschedule`, and deliberately.\n\n A disabled control tells a reader that the action exists for this\n record and is momentarily unavailable. There is no state in which a\n sent email becomes discardable, so showing the entry greyed out on one\n would be an offer this product will never honor, sitting under the\n report it is promising to destroy.\n */\n ...(draft\n ? [\n {\n key: 'discard',\n label: 'Discard draft',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: Boolean(busy),\n disabledReason: 'Another action on this email is still running',\n onClick: () => void handleDiscard(),\n } as RowActionsMenuItem,\n ]\n : []),\n ]\n\n /*\n * The one contained button, and what it is per state.\n *\n * `draft` and `scheduled` share it: the email has not gone out, so the act\n * is to make it go. A `sent` email's is the follow-up. A `canceled` one has\n * no primary act at all — it was withdrawn deliberately, and offering a way\n * to un-withdraw it would be a resurrect path this model does not have —\n * and neither does one that is mid-send.\n */\n const primaryAction = held ? null : (draft || scheduled) && !midFlight ? (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={Boolean(busy)}\n onClick={() => void handleSendNow()}\n >\n {busy === 'sendNow' ? 'Checking…' : 'Send now'}\n </Button>\n ) : midFlight ? (\n /*\n A CAMPAIGN THAT IS ALREADY GOING OUT HAS ONE ACT: STOPPING IT.\n\n \"Send now\" is withheld rather than disabled, and the reason is not\n cosmetic — `sendNow` re-resolves the WHOLE audience and mails it, with\n no subtraction of anyone already reached, so pressing it on an email\n between batches sends a second copy to every person who has had the\n first. Withholding it leaves exactly one primary action, and it is the\n one a merchant watching a send go wrong actually wants.\n */\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"error\"\n disabled={Boolean(busy)}\n onClick={() => void handleCancel()}\n >\n {'Stop sending'}\n </Button>\n ) : state === 'sent' ? (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={sendingMore}\n onClick={() => void handleSendToMore()}\n >\n {sendingMore ? 'Checking…' : 'Send to more recipients'}\n </Button>\n ) : null\n\n /**\n * WHERE THIS EMAIL IS WRITTEN, which is no longer this page.\n *\n * A page cannot both be \"what this email did\" and \"write this email\" — the\n * first is a report the reader scrolls, the second is a form with one\n * irreversible button — so the composer is its own route and this is the\n * link to it. Naked, because navigation should read as navigation; the one\n * CONTAINED button on this page stays the primary act of the state.\n *\n * Offered only while the copy can still be changed. An email part way\n * through a send is stored as `scheduled`, so `midFlight` is what keeps the\n * link off a message that is already reaching inboxes — the same distinction\n * \"Send now\" is withheld on.\n */\n const editHref = `${messagesPath}/${emailId}/edit`\n\n const headerActions = (\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={messagesPath}\n size=\"small\"\n color=\"primary\"\n >\n {'All messages'}\n </Button>\n {(draft || scheduled) && !midFlight && !held && !siteless ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={editHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Write this email'}\n </Button>\n ) : null}\n {templateHref ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={templateHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Open template'}\n </Button>\n ) : null}\n {siteless ? null : primaryAction}\n {siteless ? null : (\n <RowActionsMenu label={subject} items={overflowItems} />\n )}\n </Stack>\n )\n\n if (notFound) {\n return (\n <CardDisplay\n header={'Email'}\n help={emailDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n {/*\n * Not \"no data\". An email that cannot be read is a different\n * situation from one with no engagement, and rendering an empty\n * report for the first is how somebody comes to believe a message\n * they sent reached nobody.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This email could not be loaded. It may have been deleted.'}\n </Typography>\n </CardDisplay>\n )\n }\n\n return (\n <Stack spacing={3}>\n {/* The page heading and the trail name the message; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={email ? subject : undefined} />\n {held ? (\n <Alert severity=\"warning\">{campaignHeldForReviewNotice(email as never)}</Alert>\n ) : null}\n {siteless ? (\n <Alert severity=\"info\">\n {'This email does not record which site it was sent as, so it can ' +\n 'only be managed from that site’s own Emails page. Its figures ' +\n 'below are complete.'}\n </Alert>\n ) : null}\n <CardDisplay\n header={'Email'}\n help={emailDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {unsent && plainTextState.stale ? (\n <Alert severity=\"warning\">\n {'The design has been edited since this email’s plain-text ' +\n 'version was written, so the two halves may no longer say the ' +\n 'same thing. Nothing has overwritten what was written — open ' +\n 'this email to read it, or take the design’s text instead.'}\n </Alert>\n ) : null}\n\n <Divider />\n\n <Section title=\"Where this went\">\n <ScrollTable size=\"small\">\n <TableBody>\n {/*\n WHAT THIS EMAIL IS DOING, not the field it stores.\n\n An email delivering an audience larger than one batch is\n written back as `scheduled` between runs — the state the\n processor claims to resume it — so the stored status read\n \"Scheduled\" on a page reporting five hundred deliveries.\n */}\n <TableRow>\n <TableCell>{'State'}</TableCell>\n <TableCell align=\"right\">\n <Chip\n size=\"small\"\n color={\n display.state === 'sending'\n ? 'info'\n : display.state === 'stopped' || held\n ? 'warning'\n : undefined\n }\n label={display.label}\n />\n </TableCell>\n </TableRow>\n {midFlight ? (\n <TableRow>\n <TableCell>{'Next batch'}</TableCell>\n <TableCell align=\"right\">\n {`${display.progress.remaining.toLocaleString()} still ` +\n 'to reach, ' +\n (display.progress.nextAtMs\n ? `next run ${new Date(\n display.progress.nextAtMs,\n ).toLocaleString()}`\n : 'next run due')}\n </TableCell>\n </TableRow>\n ) : null}\n <TableRow>\n <TableCell>\n {state === 'sent' ? 'Sent' : 'Scheduled for'}\n </TableCell>\n <TableCell align=\"right\">\n {sendTimeMs\n ? new Date(sendTimeMs).toLocaleString()\n : 'not recorded'}\n </TableCell>\n </TableRow>\n {/*\n An email that has been sent more than once, said out loud.\n Every figure below covers all of them, and a reader who took\n the single `Sent` date above for the whole story would read\n the delivery numbers as one mailing's.\n */}\n {sendCount > 1 ? (\n <TableRow>\n <TableCell>{'Sends'}</TableCell>\n <TableCell align=\"right\">\n {`${sendCount.toLocaleString()}, most recently ` +\n (lastSentMs\n ? new Date(lastSentMs).toLocaleString()\n : 'not recorded')}\n </TableCell>\n </TableRow>\n ) : null}\n {displayName ? (\n <TableRow>\n <TableCell>{'Name'}</TableCell>\n <TableCell align=\"right\">{displayName}</TableCell>\n </TableRow>\n ) : null}\n {/*\n THE ADDRESS THIS MESSAGE ACTUALLY LEFT AS.\n\n Always a row. A site's sending identity can move — a domain\n verifies, a mailbox is renamed, a sender changes — so the\n question \"what did my recipients see\" has an answer only if\n the send wrote one down, and a page that omitted the row for\n the sends that did not would make an unanswerable question\n look like one nobody asked.\n\n Three states, and they are three different facts. A\n recorded address is what went out. An unsent email has no\n address yet, which is not the same as having lost one. And a\n message sent before the send began stamping its sender says\n so plainly rather than being handed today's identity, which\n would be this page inventing history.\n */}\n <TableRow>\n <TableCell>{'Sent as'}</TableCell>\n <TableCell align=\"right\">\n {sentAs.recorded ? (\n <Typography variant=\"body2\" sx={{ fontFamily: 'monospace' }}>\n {sentAs.from}\n </Typography>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {unsent\n ? 'not sent yet'\n : neverSent\n ? 'not sent'\n : 'not recorded'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n {/*\n The two rows that only exist once there is a sender to\n describe. Both name what an ABSENT value means rather than\n leaving a blank: a message with no display name showed the\n address on its own, and one with no reply address takes\n replies where it was sent from. Neither is missing\n information — each is a fact the record states by omission.\n */}\n {sentAs.recorded ? (\n <TableRow>\n <TableCell>{'From name'}</TableCell>\n <TableCell align=\"right\">\n {sentAs.fromName ?? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The address on its own'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n ) : null}\n {sentAs.recorded ? (\n <TableRow>\n <TableCell>{'Reply-to'}</TableCell>\n <TableCell align=\"right\">\n {sentAs.replyTo ?? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The sending address'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n ) : null}\n <TableRow>\n <TableCell>{'Campaign'}</TableCell>\n <TableCell align=\"right\">\n {/*\n The campaign's page belongs to the Marketing console, so\n this href is built from the sibling hub rather than this\n surface's own. Plain text until that hub resolves: a\n link with no destination is worse than none.\n */}\n {marketingHub ? (\n <AppLink href={`${marketingHub}/campaigns/${campaignId}`}>\n {'Open the campaign'}\n </AppLink>\n ) : (\n 'Open the campaign'\n )}\n </TableCell>\n </TableRow>\n <TableRow>\n <TableCell>{'List'}</TableCell>\n <TableCell align=\"right\">\n {emailAudienceLabel(email)}\n </TableCell>\n </TableRow>\n {/*\n Always a row, never a hidden one. A template this email did\n not use and a template row that was not rendered look\n identical to a reader, and the second sends them looking for\n a link that was never going to be there.\n */}\n <TableRow>\n <TableCell>{'Template'}</TableCell>\n <TableCell align=\"right\">\n {templateHref ? (\n <AppLink\n href={templateHref}\n >\n {template?.displayName ?? 'Untitled template'}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Written as plain text in the composer'}\n </Typography>\n )}\n </TableCell>\n </TableRow>\n </TableBody>\n </ScrollTable>\n {/*\n * The list is named as the SEND recorded it, and saying so is\n * what stops a renamed or deleted list quietly rewriting the\n * history of a message that went out months ago.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The list and the sender are recorded as they were when this ' +\n 'email was sent, not as this site is configured now.'}\n </Typography>\n </Section>\n\n <Divider />\n\n {/*==========================================\n * AN EMAIL THAT HAS NOT BEEN SENT HAS NO REPORT.\n *\n * Not an empty one — none. Every figure below divides or counts\n * something that only exists once mail has gone out, and an unsent\n * email carries no `stats` at all, so rendering the sections would\n * publish a column of zeros and a delivery rate of 0%. That reads\n * as \"this reached nobody\", which is a claim about a send that\n * happened; the truth is that no send has happened.\n *\n * The same reasoning the rate rows already follow, one level up: a\n * rate whose denominator is unrecorded renders absent rather than\n * as 0%, and a report whose whole subject is unrecorded renders\n * absent rather than as zeros.\n *=========================================*/}\n {unsent || neverSent ? (\n <Section title=\"Delivery\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {neverSent\n ? 'This email was not sent to anybody, so there is ' +\n 'nothing to report.'\n : sending\n ? 'This email is being sent right now. Its figures appear ' +\n 'here once the send finishes.'\n : draft\n ? 'This email has not been sent, so there is nothing to ' +\n 'report yet. Write this email — the link is in the ' +\n 'header — then send it or put it on the schedule.'\n : 'This email has not been sent yet. Its figures appear ' +\n 'here once it goes out.'}\n </Typography>\n </Section>\n ) : (\n <>\n <Section title=\"Delivery\">\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Addressed\"\n value={report.recipients}\n note=\"after the per-send cap\"\n />\n <Figure\n label=\"Sent\"\n value={report.sent}\n note=\"accepted by the provider\"\n />\n <Figure\n label=\"Delivered\"\n value={report.delivered}\n note=\"accepted by the receiving server\"\n />\n <Figure label=\"Bounced\" value={report.bounced} note=\"of sent\" />\n <Figure\n label=\"Marked as spam\"\n value={report.complained}\n note=\"of delivered\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Engagement\">\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Opens\"\n value={report.opens}\n note=\"every open, repeats included\"\n />\n <Figure\n label=\"Readers who opened\"\n value={report.uniqueOpens}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Clicks\"\n value={report.clicks}\n note=\"every click, repeats included\"\n />\n <Figure\n label=\"Readers who clicked\"\n value={report.uniqueClicks}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Unsubscribed\"\n value={report.unsubscribes}\n note=\"through this email's link\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Rates\">\n <Stack spacing={1}>\n <RateRow label=\"Delivery rate\" rate={report.rates.delivery} />\n <RateRow label=\"Open rate\" rate={report.rates.open} />\n <RateRow label=\"Click rate\" rate={report.rates.click} />\n <RateRow\n label=\"Click-to-open rate\"\n rate={report.rates.clickToOpen}\n />\n <RateRow label=\"Bounce rate\" rate={report.rates.bounce} />\n <RateRow label=\"Complaint rate\" rate={report.rates.complaint} />\n <RateRow\n label=\"Unsubscribe rate\"\n rate={report.rates.unsubscribe}\n />\n </Stack>\n </Section>\n\n {report.populations.length ? (\n <>\n <Divider />\n <Section title=\"Who this was allowed to reach\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Measured when this email was sent, and stored as it was ' +\n 'then. These figures describe the send, not the audience ' +\n 'as it stands today.'}\n </Typography>\n <ScrollTable size=\"small\">\n <TableBody>\n {report.populations.map((population) => (\n <TableRow key={population.id}>\n <TableCell>{population.label}</TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {population.count.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`of ${population.of.toLocaleString()} ${population.ofLabel}`}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n </Section>\n </>\n ) : null}\n\n <Divider />\n\n <Section title=\"Links\">\n {linkReport.rows.length ? (\n <>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n <TableCell align=\"right\">{'Share'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {linkReport.rows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {row.clicks.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.share\n ? `${percent(row.share.value)} of ${row.share.denominator.toLocaleString()} ${row.share.denominatorLabel}`\n : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Counted by address and path — query strings are dropped, ' +\n 'so two links to the same page with different tracking ' +\n 'parameters count as one row.'}\n </Typography>\n {linkReport.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${linkReport.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived without a destination and are not in this table.'}\n </Typography>\n ) : null}\n {linkReport.overflowClicks ? (\n <Alert severity=\"info\">\n {'This email has more distinct destinations than the ' +\n `rollup keeps. ${linkReport.overflowClicks.toLocaleString()} ` +\n 'clicks landed on links past that limit and are counted ' +\n 'in the click total above but not in this table.'}\n </Alert>\n ) : null}\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.clicks\n ? 'Clicks were recorded for this email, but none of them ' +\n 'carried a destination, so there is nothing to break down ' +\n 'by link.'\n : 'No link clicks have been recorded for this email.'}\n </Typography>\n )}\n </Section>\n </>\n )}\n </Stack>\n </CardDisplay>\n\n {hostId ? <EmailRecipientsCard hostId={hostId} emailId={emailId} /> : null}\n\n {/*\n * Last, and its own card. The numbers are what a reader came for and\n * the preview is the tallest thing on the page — above them it pushes\n * every figure below the fold.\n *\n * `header` rather than `title`: `CardDisplay` has no `title` prop, so\n * one spreads through to the MUI `Card` root and lands on the DOM as a\n * hover tooltip, leaving the card with no heading at all. The gutters\n * are named for the same reason — without them the 640px frame sits\n * flush against the card's edge.\n */}\n {/* A design is the sending site's, so there is none to draw without one. */}\n {hostId ? (\n <CardDisplay\n header={'Preview'}\n help={previewDocsHelp}\n contentGutterX\n contentGutterY\n >\n {templateScreenId ? (\n <EmailDesignPreview\n hostId={hostId}\n nodes={templateVersion?.nodes}\n loading={template === undefined || templateVersion === undefined}\n subject={subject}\n preheader={String(template?.emailPreheader ?? '')}\n emptyMessage={\n 'The template this email was built from is empty or has ' +\n 'been deleted, so there is nothing to draw.'\n }\n note={\n 'The template as it stands today. The mail itself is ' +\n 'rendered per recipient at send time and not kept, so a ' +\n 'template edited since this went out previews as it is now.'\n }\n />\n ) : (\n <EmailDesignPreview\n hostId={hostId}\n nodes={undefined}\n text={composedBody}\n loading={email === undefined}\n subject={subject}\n emptyMessage={\n 'This email carries no body, so there is nothing to draw.'\n }\n note={\n 'Written as plain text in the composer. Merge tokens are ' +\n 'left standing here — the mail itself resolves them per ' +\n 'recipient at send time and is not kept.'\n }\n />\n )}\n </CardDisplay>\n ) : null}\n\n {/*\n * Editing in a DRAWER, never a form above the content. The name is the\n * one detail a sent email still owns — see the drawer's own header for\n * why the subject, body, audience and topic are not on offer once mail\n * has been delivered.\n */}\n <EmailEditDrawer\n open={editing !== null}\n onClose={() => setEditing(null)}\n field={editing === 'schedule' ? 'schedule' : 'details'}\n title={\n editing === 'schedule'\n ? scheduled\n ? 'Reschedule this email'\n : 'Schedule this email'\n : 'Edit details'\n }\n submitLabel={\n editing === 'schedule'\n ? scheduled\n ? 'Reschedule'\n : 'Schedule'\n : 'Save'\n }\n displayName={displayName}\n sendAtMs={scheduled ? sendTimeMs : 0}\n busy={Boolean(busy)}\n note={\n editing === 'schedule'\n ? 'The email goes out at this time. You can send it sooner, or ' +\n 'cancel it, from this page.'\n : unsent\n ? 'The name is for finding this email in your own lists. The ' +\n 'subject and the message are written on this email’s own ' +\n 'compose page.'\n : 'This email has been sent, so its subject, message and ' +\n 'audience describe mail that is already in inboxes and can ' +\n 'no longer be changed. Its name is yours and stays editable.'\n }\n onSubmit={(values) =>\n void (editing === 'schedule'\n ? handleReschedule(values)\n : handleRename(values))\n }\n />\n </Stack>\n )\n}\nEmailDetail.displayName = 'EmailDetail'\n\nexport default EmailDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","pluginRecordHref","mdiCalendarClockOutline","mdiCloseCircleOutline","mdiDeleteOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","ScrollTable","useSnackbar","useFirestore","useFirestoreDoc","Alert","Button","Chip","Divider","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","useRouter","useCallback","useMemo","useState","sendLinkReport","campaignReport","campaignHeldForReviewNotice","campaignSendDisplay","CAMPAIGN_SEND_CONTAINER_FIELD","emailAudienceLabel","emailIsUnsent","emailNeverSent","emailSendTimeMs","emailSentAs","emailPlainTextState","useMarketingHubPath","siteRecordRouteContext","useRecordRouteContext","campaignSendDoc","campaignSendReportDoc","useMarketingOrgId","useMarketingOrgMount","CampaignDesignPreview","EmailDesignPreview","EmailEditDrawer","EmailRecipientsCard","Figure","percent","RateRow","Section","useCampaignManageApi","useCampaignSendApi","previewDocsHelp","anchor","emailDocsHelp","EmailDetail","props","sentAs","emailId","basePath","marketingHub","orgMount","orgId","hostId","firestore","data","email","status","notFound","String","siteless","Boolean","messagesPath","templateContext","links","templateScreenId","templateHrefOf","id","templateHref","template","templateVersionId","versionId","templateVersion","plainTextState","plainText","plainTextVersionId","report","stats","linkReport","subject","composedBody","body","sendTimeMs","state","display","midFlight","held","unsent","neverSent","displayName","sendCount","Number","lastSentMs","lastSentAt","sentAt","campaignId","campaignSendApi","confirm","enqueueSnackbar","sendingMore","setSendingMore","handleSendToMore","counted","result","action","dryRun","response","ok","payload","error","variant","allowDuplicate","reaching","sendable","already","alreadyReached","persist","agreed","title","description","toLocaleString","confirmationText","then","catch","sent","console","busy","setBusy","editing","setEditing","runAction","key","request","success","failure","handleSendNow","handleCancel","reached","progress","left","remaining","handleReschedule","values","done","sendAtMs","Date","manageApi","router","handleDiscard","push","handleRename","scheduled","draft","sending","overflowItems","label","icon","path","size","onClick","destructive","disabled","disabledReason","primaryAction","color","editHref","headerActions","direction","spacing","useFlexGap","sx","alignItems","flexWrap","component","componentVariant","nativeButton","href","items","header","help","contentGutterX","contentGutterY","HeaderProps","undefined","severity","caveats","map","caveat","message","stale","align","nextAtMs","recorded","fontFamily","from","fromName","replyTo","value","recipients","note","delivered","bounced","complained","opens","uniqueOpens","clicks","uniqueClicks","unsubscribes","rate","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","populations","length","population","fontWeight","count","of","ofLabel","rows","row","wordBreak","url","share","denominator","denominatorLabel","unattributedClicks","overflowClicks","nodes","loading","preheader","emailPreheader","emptyMessage","text","onClose","field","submitLabel","onSubmit"],"mappings":"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"}
|
|
@@ -334,7 +334,8 @@ export interface CampaignResume {
|
|
|
334
334
|
* come. The state this function exists for.
|
|
335
335
|
* - `sent` — finished, whether in one batch or six.
|
|
336
336
|
* - `stopped` — finished with people it never addressed: canceled mid-flight,
|
|
337
|
-
* failed mid-flight, or stopped by the batch guard
|
|
337
|
+
* failed mid-flight, or stopped by the batch guard; or canceled or failed
|
|
338
|
+
* before it sent anything at all. The count is what makes
|
|
338
339
|
* this legible rather than alarming — an email that reached 2,400 of 3,000
|
|
339
340
|
* and stopped is a different conversation from one that reached nobody.
|
|
340
341
|
*/
|
|
@@ -270,6 +270,19 @@ export function campaignSendProgress(send) {
|
|
|
270
270
|
label: status === 'sending' ? 'Sending' : 'Scheduled'
|
|
271
271
|
};
|
|
272
272
|
}
|
|
273
|
+
// Canceled, or failed, before a single message went out, with nobody left
|
|
274
|
+
// waiting: it never sent. "Sent to 0" would describe a send that happened.
|
|
275
|
+
if ((status === 'canceled' || status === 'failed') && reached === 0 && remaining === 0) {
|
|
276
|
+
return {
|
|
277
|
+
state: 'stopped',
|
|
278
|
+
reached: 0,
|
|
279
|
+
audience,
|
|
280
|
+
remaining: 0,
|
|
281
|
+
batch,
|
|
282
|
+
nextAtMs: 0,
|
|
283
|
+
label: status === 'canceled' ? 'Canceled before it was sent' : 'Failed before it was sent'
|
|
284
|
+
};
|
|
285
|
+
}
|
|
273
286
|
if (remaining > 0) {
|
|
274
287
|
const why = status === 'canceled' ? 'canceled' : status === 'failed' ? 'stopped by an error' : 'stopped';
|
|
275
288
|
return {
|
|
@@ -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 /**\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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"}
|
|
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; or canceled or failed\n * before it sent anything at all. 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 // Canceled, or failed, before a single message went out, with nobody left\n // waiting: it never sent. \"Sent to 0\" would describe a send that happened.\n if (\n (status === 'canceled' || status === 'failed') &&\n reached === 0 &&\n remaining === 0\n ) {\n return {\n state: 'stopped',\n reached: 0,\n audience,\n remaining: 0,\n batch,\n nextAtMs: 0,\n label:\n status === 'canceled'\n ? 'Canceled before it was sent'\n : 'Failed before it was sent',\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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"}
|
|
@@ -123,6 +123,13 @@ export declare function emailStateLabel(status: unknown): string;
|
|
|
123
123
|
* as a rate rendered over a denominator it does not name.
|
|
124
124
|
*/
|
|
125
125
|
export declare function emailIsUnsent(record: Record<string, any> | null | undefined): boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Whether an email was withdrawn before it was mailed to anybody: canceled, or
|
|
128
|
+
* failed, with nothing delivered. Unlike {@link emailIsUnsent} it never will
|
|
129
|
+
* be, so a surface says it was not sent rather than "not sent yet", and has
|
|
130
|
+
* no report to draw for it.
|
|
131
|
+
*/
|
|
132
|
+
export declare function emailNeverSent(record: Record<string, any> | null | undefined): boolean;
|
|
126
133
|
/**
|
|
127
134
|
* Reader-facing names for the built-in audience kinds.
|
|
128
135
|
*
|
|
@@ -148,6 +148,18 @@
|
|
|
148
148
|
const value = String((_ref = record == null ? void 0 : record['status']) != null ? _ref : '');
|
|
149
149
|
return value === 'draft' || value === 'scheduled' || value === 'sending';
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether an email was withdrawn before it was mailed to anybody: canceled, or
|
|
153
|
+
* failed, with nothing delivered. Unlike {@link emailIsUnsent} it never will
|
|
154
|
+
* be, so a surface says it was not sent rather than "not sent yet", and has
|
|
155
|
+
* no report to draw for it.
|
|
156
|
+
*/ export function emailNeverSent(record) {
|
|
157
|
+
var _ref, _ref1;
|
|
158
|
+
var _record_stats;
|
|
159
|
+
const value = String((_ref = record == null ? void 0 : record['status']) != null ? _ref : '');
|
|
160
|
+
const sent = Math.floor(Number((_ref1 = record == null ? void 0 : (_record_stats = record['stats']) == null ? void 0 : _record_stats['sent']) != null ? _ref1 : 0));
|
|
161
|
+
return (value === 'canceled' || value === 'failed') && !(sent > 0);
|
|
162
|
+
}
|
|
151
163
|
/**
|
|
152
164
|
* Reader-facing names for the built-in audience kinds.
|
|
153
165
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/email-record.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 * READING ONE MESSAGE RECORD.\n *\n * A message — an email that was or will be sent — is a document under\n * `hosts/{hostId}/campaigns`. Two facts about that document are awkward\n * enough that every surface reading it gets them wrong the same way, so they\n * are decided here once.\n */\n\n/**\n * When a message went out, or is due to.\n *\n * **There is no one SEND date field.** The send path writes `{status:'sent',\n * sentAt}` from one branch and `{status:'scheduled', sendAtMs}` from another.\n * So a list ordered on either field in Firestore does not mis-sort —\n * `orderBy` DROPS every document missing the ordered field — and half the\n * messages simply vanish. Sorting on this instead keeps both kinds in one\n * ordered list.\n *\n * `sentAt` is a Firestore `Timestamp`, which the client SDK gives as an\n * object with `seconds` and `toMillis()`; a document read from cache while a\n * write is pending can carry a sentinel with neither, which reads as 0 and\n * sorts last rather than throwing.\n *\n * A DRAFT correctly answers 0 here: it has no send time, and inventing one\n * from its creation would put an unsent message on the timeline of mail that\n * went out. Ordering a list is {@link emailListTimeMs}'s job.\n *\n * @returns epoch ms, or 0 when the message has no time at all.\n */\nexport function emailSendTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n // A send held for staff review is parked at a time no run reaches\n // (AGL-3356). That is a mechanism, not a send time, and printed it reads as\n // the year 9999 — so a held send answers \"no time\", like a draft, and the\n // lists order it by when it was created.\n if (\n record?.['staffReview']?.['state'] === 'held' &&\n String(record?.['status'] ?? '') === 'scheduled'\n ) {\n return 0\n }\n const sentAt = record?.['sentAt'] as\n | { toMillis?: () => number; seconds?: number }\n | undefined\n if (typeof sentAt?.toMillis === 'function') return sentAt.toMillis()\n if (typeof sentAt?.seconds === 'number') return sentAt.seconds * 1000\n const scheduled = Number(record?.['sendAtMs'] ?? 0)\n return Number.isFinite(scheduled) ? scheduled : 0\n}\n\n/**\n * The field every writer of a message stamps when it MINTS the document.\n *\n * The one date on every message, which is what neither `sentAt` nor\n * `sendAtMs` is — each is written by one branch of the send path and neither\n * is on both kinds. A field carried by every record is the precondition for\n * ordering this collection in Firestore rather than in the browser, because\n * `orderBy` drops documents that lack the ordered field instead of\n * mis-sorting them.\n *\n * Epoch milliseconds rather than a `Timestamp`, matching `sendAtMs` beside\n * it: the two are compared against each other on every list that draws\n * drafts and sends together, and a comparison that has to unwrap one side\n * first is a comparison somebody writes wrong.\n */\nexport const EMAIL_CREATED_AT_FIELD = 'createdAtMs'\n\n/**\n * When a message was created, for the records that carry it.\n *\n * `null` — not 0 — for a record carrying no {@link EMAIL_CREATED_AT_FIELD},\n * because 0 is a real instant at the far end of the sort and \"we do not know\"\n * is not \"1970\". A null orders by the record's send time instead, so the list\n * is correct for a record from any era.\n */\nexport function emailCreatedAtMs(\n record: Record<string, any> | null | undefined,\n): number | null {\n const created = Number(record?.[EMAIL_CREATED_AT_FIELD] ?? Number.NaN)\n return Number.isFinite(created) && created > 0 ? created : null\n}\n\n/**\n * WHERE A MESSAGE SITS IN A LIST OF MESSAGES.\n *\n * Its send time where it has one, and its creation time where it does not.\n *\n * A draft has neither `sentAt` nor `sendAtMs`, so ordering a list on the send\n * time alone gives every draft the key 0 and files it below mail sent years\n * ago — the newest thing a merchant did, at the bottom of the page, behind\n * whatever paging the list has. Falling back to creation puts it where the\n * merchant left it.\n *\n * The fallback is deliberately not the other way round. A SENT message orders\n * by when it went out, never by when it was drafted: the list is a record of\n * what happened, and a message drafted in March and sent in June belongs in\n * June.\n *\n * @returns epoch ms, or 0 for a record with no time of any kind — a draft\n * written before the created stamp existed, which sorts last exactly as it\n * did before.\n */\nexport function emailListTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n return emailSendTimeMs(record) || emailCreatedAtMs(record) || 0\n}\n\n/**\n * Reader-facing text for a message's state.\n *\n * The KEYS are persisted values written by the send path and read by the\n * scheduled-campaign processor — nothing here may change them, including\n * `canceled`, which is already the American spelling and is a stored enum in\n * either case. Only the right-hand side is display text, and an unrecognized\n * state falls through as itself rather than being flattened into one of\n * these: a state this list cannot name is worth seeing.\n *\n * `draft` is an email that has been created and not sent. `sending` is the\n * claim the scheduled processor and the send-now route both take before they\n * mail, and it is named here because a merchant who reloads mid-send would\n * otherwise be shown the raw token.\n *\n * ⚠️ **Not what a console row should draw.** This names the STORED value, and\n * an email delivering an audience larger than one batch is stored as\n * `scheduled` between runs — so a row rendering this says \"Scheduled\" about a\n * send that has already put five hundred messages in five hundred inboxes.\n * `campaignSendDisplay` in `campaign-container.ts` reads the counters beside\n * the status and is what every surface here draws. This remains the honest\n * answer to the narrower question of what one stored token means.\n */\nexport function emailStateLabel(status: unknown): string {\n const value = String(status ?? '')\n if (value === 'sent') return 'Sent'\n if (value === 'scheduled') return 'Scheduled'\n if (value === 'canceled') return 'Canceled'\n if (value === 'draft') return 'Draft'\n if (value === 'sending') return 'Sending'\n return value || 'Unknown'\n}\n\n/**\n * Whether an email has yet been mailed to anybody.\n *\n * The surfaces that report on a send need this because an unsent email has no\n * `stats` at all, and a report that divides into an absent denominator\n * presents \"not sent yet\" as a delivery rate of 0% — the same class of fault\n * as a rate rendered over a denominator it does not name.\n */\nexport function emailIsUnsent(\n record: Record<string, any> | null | undefined,\n): boolean {\n const value = String(record?.['status'] ?? '')\n return value === 'draft' || value === 'scheduled' || value === 'sending'\n}\n\n/**\n * Reader-facing names for the built-in audience kinds.\n *\n * The KEYS are the persisted `audience` values written on every message since\n * the composer shipped; only the values on the right are display text. A\n * `list` audience is deliberately absent — it is not a kind of audience, it\n * is a NAMED one, and naming it is {@link emailAudienceLabel}'s job.\n */\nexport const EMAIL_AUDIENCE_LABELS: Record<string, string> = {\n leads: 'All leads',\n members: 'All site members',\n segment: 'A contact segment',\n manual: 'Addresses typed into the composer',\n}\n\n/**\n * Which audience a message went to, named for a reader.\n *\n * A list send is named by the name the SEND recorded, never by the name the\n * list carries today: resolving it now would let a rename rewrite the history\n * of a message that went out months ago, and a deletion erase it. A send that\n * recorded no name says so instead of printing a document id, which is not\n * something a merchant recognises as a list.\n */\nexport function emailAudienceLabel(\n record: Record<string, any> | null | undefined,\n): string {\n const audience = String(record?.['audience'] ?? '')\n if (audience !== 'list') {\n return EMAIL_AUDIENCE_LABELS[audience] ?? audience ?? 'Not recorded'\n }\n const name = String(record?.['listName'] ?? '')\n if (name) return name\n return record?.['listId']\n ? 'A list this send did not name'\n : 'A list this send did not record'\n}\n\n/**\n * The sender a message actually left with, as this record has it.\n *\n * `recorded` is the whole point of the shape. The address a send goes out on\n * is stamped by the send — see `sentAsStamp` — and every message sent before\n * that stamp existed carries nothing, so a surface has to be able to tell an\n * unrecorded sender apart from one it could compose. Composing it is the\n * thing that must not happen: a site's sending identity is a setting, and\n * resolving it now would answer \"what would this send as today\" on a report\n * whose subject is a message that went out months ago, under whatever address\n * the site held then.\n *\n * The same rule {@link emailAudienceLabel} follows for a list name, and for\n * the same reason: the report says what happened, not what would happen now.\n *\n * `replyTo` is null on a message that set none, which is not missing\n * information — a message with no `Reply-To:` takes replies at its `from`,\n * and that is a fact the record states by omission rather than one it lost.\n * The difference only reads correctly when `recorded` is true, which is why\n * a caller must consult that first.\n */\nexport interface EmailSentAs {\n /** False on a message sent before the send began stamping its sender. */\n recorded: boolean\n from: string | null\n /** The display name recipients saw, or null when the address stood alone. */\n fromName: string | null\n /** Where replies were pointed, or null when they go to `from`. */\n replyTo: string | null\n}\n\nexport function emailSentAs(\n record: Record<string, any> | null | undefined,\n): EmailSentAs {\n const stamp = record?.['sentAs'] as Record<string, any> | null | undefined\n const from = String(stamp?.['from'] ?? '').trim()\n // The ADDRESS is what makes the stamp a record of a send. A `sentAs` map\n // carrying only a display name is a partial write, and reporting it as\n // recorded would put a name on screen beside an address nothing supplied.\n if (!from) {\n return { recorded: false, from: null, fromName: null, replyTo: null }\n }\n return {\n recorded: true,\n from,\n fromName: String(stamp?.['fromName'] ?? '').trim() || null,\n replyTo: String(stamp?.['replyTo'] ?? '').trim() || null,\n }\n}\n"],"names":["emailSendTimeMs","record","String","sentAt","toMillis","seconds","scheduled","Number","isFinite","EMAIL_CREATED_AT_FIELD","emailCreatedAtMs","created","NaN","emailListTimeMs","emailStateLabel","status","value","emailIsUnsent","EMAIL_AUDIENCE_LABELS","leads","members","segment","manual","emailAudienceLabel","audience","name","emailSentAs","stamp","from","trim","recorded","fromName","replyTo"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/email-record.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 * READING ONE MESSAGE RECORD.\n *\n * A message — an email that was or will be sent — is a document under\n * `hosts/{hostId}/campaigns`. Two facts about that document are awkward\n * enough that every surface reading it gets them wrong the same way, so they\n * are decided here once.\n */\n\n/**\n * When a message went out, or is due to.\n *\n * **There is no one SEND date field.** The send path writes `{status:'sent',\n * sentAt}` from one branch and `{status:'scheduled', sendAtMs}` from another.\n * So a list ordered on either field in Firestore does not mis-sort —\n * `orderBy` DROPS every document missing the ordered field — and half the\n * messages simply vanish. Sorting on this instead keeps both kinds in one\n * ordered list.\n *\n * `sentAt` is a Firestore `Timestamp`, which the client SDK gives as an\n * object with `seconds` and `toMillis()`; a document read from cache while a\n * write is pending can carry a sentinel with neither, which reads as 0 and\n * sorts last rather than throwing.\n *\n * A DRAFT correctly answers 0 here: it has no send time, and inventing one\n * from its creation would put an unsent message on the timeline of mail that\n * went out. Ordering a list is {@link emailListTimeMs}'s job.\n *\n * @returns epoch ms, or 0 when the message has no time at all.\n */\nexport function emailSendTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n // A send held for staff review is parked at a time no run reaches\n // (AGL-3356). That is a mechanism, not a send time, and printed it reads as\n // the year 9999 — so a held send answers \"no time\", like a draft, and the\n // lists order it by when it was created.\n if (\n record?.['staffReview']?.['state'] === 'held' &&\n String(record?.['status'] ?? '') === 'scheduled'\n ) {\n return 0\n }\n const sentAt = record?.['sentAt'] as\n | { toMillis?: () => number; seconds?: number }\n | undefined\n if (typeof sentAt?.toMillis === 'function') return sentAt.toMillis()\n if (typeof sentAt?.seconds === 'number') return sentAt.seconds * 1000\n const scheduled = Number(record?.['sendAtMs'] ?? 0)\n return Number.isFinite(scheduled) ? scheduled : 0\n}\n\n/**\n * The field every writer of a message stamps when it MINTS the document.\n *\n * The one date on every message, which is what neither `sentAt` nor\n * `sendAtMs` is — each is written by one branch of the send path and neither\n * is on both kinds. A field carried by every record is the precondition for\n * ordering this collection in Firestore rather than in the browser, because\n * `orderBy` drops documents that lack the ordered field instead of\n * mis-sorting them.\n *\n * Epoch milliseconds rather than a `Timestamp`, matching `sendAtMs` beside\n * it: the two are compared against each other on every list that draws\n * drafts and sends together, and a comparison that has to unwrap one side\n * first is a comparison somebody writes wrong.\n */\nexport const EMAIL_CREATED_AT_FIELD = 'createdAtMs'\n\n/**\n * When a message was created, for the records that carry it.\n *\n * `null` — not 0 — for a record carrying no {@link EMAIL_CREATED_AT_FIELD},\n * because 0 is a real instant at the far end of the sort and \"we do not know\"\n * is not \"1970\". A null orders by the record's send time instead, so the list\n * is correct for a record from any era.\n */\nexport function emailCreatedAtMs(\n record: Record<string, any> | null | undefined,\n): number | null {\n const created = Number(record?.[EMAIL_CREATED_AT_FIELD] ?? Number.NaN)\n return Number.isFinite(created) && created > 0 ? created : null\n}\n\n/**\n * WHERE A MESSAGE SITS IN A LIST OF MESSAGES.\n *\n * Its send time where it has one, and its creation time where it does not.\n *\n * A draft has neither `sentAt` nor `sendAtMs`, so ordering a list on the send\n * time alone gives every draft the key 0 and files it below mail sent years\n * ago — the newest thing a merchant did, at the bottom of the page, behind\n * whatever paging the list has. Falling back to creation puts it where the\n * merchant left it.\n *\n * The fallback is deliberately not the other way round. A SENT message orders\n * by when it went out, never by when it was drafted: the list is a record of\n * what happened, and a message drafted in March and sent in June belongs in\n * June.\n *\n * @returns epoch ms, or 0 for a record with no time of any kind — a draft\n * written before the created stamp existed, which sorts last exactly as it\n * did before.\n */\nexport function emailListTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n return emailSendTimeMs(record) || emailCreatedAtMs(record) || 0\n}\n\n/**\n * Reader-facing text for a message's state.\n *\n * The KEYS are persisted values written by the send path and read by the\n * scheduled-campaign processor — nothing here may change them, including\n * `canceled`, which is already the American spelling and is a stored enum in\n * either case. Only the right-hand side is display text, and an unrecognized\n * state falls through as itself rather than being flattened into one of\n * these: a state this list cannot name is worth seeing.\n *\n * `draft` is an email that has been created and not sent. `sending` is the\n * claim the scheduled processor and the send-now route both take before they\n * mail, and it is named here because a merchant who reloads mid-send would\n * otherwise be shown the raw token.\n *\n * ⚠️ **Not what a console row should draw.** This names the STORED value, and\n * an email delivering an audience larger than one batch is stored as\n * `scheduled` between runs — so a row rendering this says \"Scheduled\" about a\n * send that has already put five hundred messages in five hundred inboxes.\n * `campaignSendDisplay` in `campaign-container.ts` reads the counters beside\n * the status and is what every surface here draws. This remains the honest\n * answer to the narrower question of what one stored token means.\n */\nexport function emailStateLabel(status: unknown): string {\n const value = String(status ?? '')\n if (value === 'sent') return 'Sent'\n if (value === 'scheduled') return 'Scheduled'\n if (value === 'canceled') return 'Canceled'\n if (value === 'draft') return 'Draft'\n if (value === 'sending') return 'Sending'\n return value || 'Unknown'\n}\n\n/**\n * Whether an email has yet been mailed to anybody.\n *\n * The surfaces that report on a send need this because an unsent email has no\n * `stats` at all, and a report that divides into an absent denominator\n * presents \"not sent yet\" as a delivery rate of 0% — the same class of fault\n * as a rate rendered over a denominator it does not name.\n */\nexport function emailIsUnsent(\n record: Record<string, any> | null | undefined,\n): boolean {\n const value = String(record?.['status'] ?? '')\n return value === 'draft' || value === 'scheduled' || value === 'sending'\n}\n\n/**\n * Whether an email was withdrawn before it was mailed to anybody: canceled, or\n * failed, with nothing delivered. Unlike {@link emailIsUnsent} it never will\n * be, so a surface says it was not sent rather than \"not sent yet\", and has\n * no report to draw for it.\n */\nexport function emailNeverSent(\n record: Record<string, any> | null | undefined,\n): boolean {\n const value = String(record?.['status'] ?? '')\n const sent = Math.floor(Number(record?.['stats']?.['sent'] ?? 0))\n return (value === 'canceled' || value === 'failed') && !(sent > 0)\n}\n\n/**\n * Reader-facing names for the built-in audience kinds.\n *\n * The KEYS are the persisted `audience` values written on every message since\n * the composer shipped; only the values on the right are display text. A\n * `list` audience is deliberately absent — it is not a kind of audience, it\n * is a NAMED one, and naming it is {@link emailAudienceLabel}'s job.\n */\nexport const EMAIL_AUDIENCE_LABELS: Record<string, string> = {\n leads: 'All leads',\n members: 'All site members',\n segment: 'A contact segment',\n manual: 'Addresses typed into the composer',\n}\n\n/**\n * Which audience a message went to, named for a reader.\n *\n * A list send is named by the name the SEND recorded, never by the name the\n * list carries today: resolving it now would let a rename rewrite the history\n * of a message that went out months ago, and a deletion erase it. A send that\n * recorded no name says so instead of printing a document id, which is not\n * something a merchant recognises as a list.\n */\nexport function emailAudienceLabel(\n record: Record<string, any> | null | undefined,\n): string {\n const audience = String(record?.['audience'] ?? '')\n if (audience !== 'list') {\n return EMAIL_AUDIENCE_LABELS[audience] ?? audience ?? 'Not recorded'\n }\n const name = String(record?.['listName'] ?? '')\n if (name) return name\n return record?.['listId']\n ? 'A list this send did not name'\n : 'A list this send did not record'\n}\n\n/**\n * The sender a message actually left with, as this record has it.\n *\n * `recorded` is the whole point of the shape. The address a send goes out on\n * is stamped by the send — see `sentAsStamp` — and every message sent before\n * that stamp existed carries nothing, so a surface has to be able to tell an\n * unrecorded sender apart from one it could compose. Composing it is the\n * thing that must not happen: a site's sending identity is a setting, and\n * resolving it now would answer \"what would this send as today\" on a report\n * whose subject is a message that went out months ago, under whatever address\n * the site held then.\n *\n * The same rule {@link emailAudienceLabel} follows for a list name, and for\n * the same reason: the report says what happened, not what would happen now.\n *\n * `replyTo` is null on a message that set none, which is not missing\n * information — a message with no `Reply-To:` takes replies at its `from`,\n * and that is a fact the record states by omission rather than one it lost.\n * The difference only reads correctly when `recorded` is true, which is why\n * a caller must consult that first.\n */\nexport interface EmailSentAs {\n /** False on a message sent before the send began stamping its sender. */\n recorded: boolean\n from: string | null\n /** The display name recipients saw, or null when the address stood alone. */\n fromName: string | null\n /** Where replies were pointed, or null when they go to `from`. */\n replyTo: string | null\n}\n\nexport function emailSentAs(\n record: Record<string, any> | null | undefined,\n): EmailSentAs {\n const stamp = record?.['sentAs'] as Record<string, any> | null | undefined\n const from = String(stamp?.['from'] ?? '').trim()\n // The ADDRESS is what makes the stamp a record of a send. A `sentAs` map\n // carrying only a display name is a partial write, and reporting it as\n // recorded would put a name on screen beside an address nothing supplied.\n if (!from) {\n return { recorded: false, from: null, fromName: null, replyTo: null }\n }\n return {\n recorded: true,\n from,\n fromName: String(stamp?.['fromName'] ?? '').trim() || null,\n replyTo: String(stamp?.['replyTo'] ?? '').trim() || null,\n }\n}\n"],"names":["emailSendTimeMs","record","String","sentAt","toMillis","seconds","scheduled","Number","isFinite","EMAIL_CREATED_AT_FIELD","emailCreatedAtMs","created","NaN","emailListTimeMs","emailStateLabel","status","value","emailIsUnsent","emailNeverSent","sent","Math","floor","EMAIL_AUDIENCE_LABELS","leads","members","segment","manual","emailAudienceLabel","audience","name","emailSentAs","stamp","from","trim","recorded","fromName","replyTo"],"mappings":"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"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { PLUGIN_FIGURE_MAX_ROWS, pluginFigureWindows, registerPluginFigureReader } from "@aglyn/aglyn/plugin-manager/plugin-figures";
|
|
17
|
+
import { figureCount, figureRow, figureStat } from "@aglyn/aglyn/app-utils/weekly-summary";
|
|
17
18
|
import { campaignReport } from "../model/campaign-report.js";
|
|
18
19
|
import { EMAIL_CREATED_AT_FIELD, emailSendTimeMs } from "../model/email-record.js";
|
|
19
20
|
import { CAMPAIGN_SEND_HOST_FIELD } from "../model/campaign-container.js";
|
|
@@ -63,6 +64,30 @@ export function marketingFigureReaders(firestore) {
|
|
|
63
64
|
description: 'Each campaign email sent from the site in the window: how many were delivered and the share of delivered messages opened and clicked, with every campaign together on the first row.',
|
|
64
65
|
scope: 'site',
|
|
65
66
|
windows: WINDOWS,
|
|
67
|
+
weeklySummary: {
|
|
68
|
+
order: 90,
|
|
69
|
+
summarize: (table)=>{
|
|
70
|
+
const all = figureRow(table, 'campaign', 'All campaigns');
|
|
71
|
+
if (!all || !figureCount(all['delivered'])) return null;
|
|
72
|
+
const sent = table.rows.filter((row)=>row !== all && figureCount(row['delivered']) > 0);
|
|
73
|
+
return {
|
|
74
|
+
title: 'Campaigns',
|
|
75
|
+
stats: [
|
|
76
|
+
{
|
|
77
|
+
label: sent.length === 1 ? 'Email sent' : 'Emails sent',
|
|
78
|
+
value: sent.length.toLocaleString('en-US')
|
|
79
|
+
},
|
|
80
|
+
figureStat(table, all, 'delivered', 'Delivered'),
|
|
81
|
+
...typeof all['openRate'] === 'number' ? [
|
|
82
|
+
figureStat(table, all, 'openRate', 'Opened')
|
|
83
|
+
] : [],
|
|
84
|
+
...typeof all['clickRate'] === 'number' ? [
|
|
85
|
+
figureStat(table, all, 'clickRate', 'Clicked')
|
|
86
|
+
] : []
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
},
|
|
66
91
|
read: async (request)=>{
|
|
67
92
|
if (!request.hostId) return {
|
|
68
93
|
ok: false,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/marketing-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRow,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport {\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { campaignReport } from '../model/campaign-report'\nimport {\n EMAIL_CREATED_AT_FIELD,\n emailSendTimeMs,\n} from '../model/email-record'\nimport { CAMPAIGN_SEND_HOST_FIELD } from '../model/campaign-container'\nimport { orgCampaignSends } from './campaign-org-refs'\nimport { compareVariants, summarizeVariantStats, type HostExperiment } from '../model/experiments'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n type CampaignConversionKind,\n type CampaignTouchChannel,\n} from '../model/campaign-conversions'\nimport {\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n type CampaignRevenueRollup,\n} from '../model/campaign-revenue'\nimport { CAMPAIGN_ATTRIBUTIONS_COLLECTION } from './campaign-attribution-store'\n\n/**\n * A site's campaign and A/B testing results as figure tables another plugin\n * reads by id (AGL-2915).\n *\n * The campaign table is what each send's report shows — delivered, and the\n * open and click rates over delivered — through `campaignReport`, the one\n * reader of a send's counters. The experiments table is what the A/B testing\n * section shows, through the same variant comparison. No recipient, address\n * or visitor reaches either: a campaign is named by its subject, a variant by\n * its name.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** Sends a campaign table reads, newest first, before it keeps those sent in the window. */\nexport const CAMPAIGN_FIGURES_READ_LIMIT = 100\n\n/** Experiments an experiments table reads. */\nexport const EXPERIMENT_FIGURES_READ_LIMIT = 20\n\n/**\n * The channels a conversion is counted by, in reading order. Each is an\n * aggregation count over the attribution records — `kind`, `channel` and the\n * window on `convertedAtMs` — so the table costs a read per thousand index\n * entries rather than a read per record, and never loads one.\n */\nexport const CONVERSION_FIGURE_CHANNELS: readonly CampaignTouchChannel[] = ['email', 'page', 'web', 'sequence']\n\n/** How a conversions table names each kind: the record it credits, in the console's words. */\nconst CONVERSION_KIND_LABELS: Readonly<Record<CampaignConversionKind, string>> = {\n form: 'Form submissions',\n lead: 'Leads',\n contact: 'Contacts',\n booking: 'Bookings',\n}\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\nconst percent = (rate: SendRate | null): number | null =>\n rate ? Math.round(rate.value * 1_000) / 10 : null\n\n/** Rates pooled across sends: every numerator over every denominator, never an average of rates. */\nfunction pooled(rates: ReadonlyArray<SendRate | null>): number | null {\n const known = rates.filter((rate): rate is SendRate => rate !== null)\n const denominator = known.reduce((sum, rate) => sum + rate.denominator, 0)\n if (!denominator) return null\n return Math.round((known.reduce((sum, rate) => sum + rate.numerator, 0) / denominator) * 1_000) / 10\n}\n\nexport function marketingFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'marketing.campaigns',\n label: 'Campaigns',\n description:\n 'Each campaign email sent from the site in the window: how many were delivered and the share of delivered messages opened and clicked, with every campaign together on the first row.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its campaigns' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one campaigns are read over' }\n }\n if (!request.orgId) return { ok: false, status: 400, error: 'This site is not part of an organization' }\n const { current } = pluginFigureWindows(request.now, request.days)\n /*\n * The organization's sends, narrowed to the ones sent as this site.\n * Served by the composite index on the site field and the creation\n * stamp — the equality and the ordering together are one index.\n */\n const snapshot = await orgCampaignSends(firestore(), request.orgId)\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId)\n .orderBy(EMAIL_CREATED_AT_FIELD, 'desc')\n .limit(CAMPAIGN_FIGURES_READ_LIMIT)\n .get()\n const sends = snapshot.docs\n .map((doc) => doc.data() ?? {})\n .filter((record) => {\n const sentAt = emailSendTimeMs(record)\n return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs\n })\n .map((record) => ({\n subject: String(record['subject'] ?? '').trim() || 'A campaign with no subject',\n report: campaignReport(record['stats'] as SendStats | undefined),\n }))\n const rows: PluginFigureRow[] = [\n {\n campaign: 'All campaigns',\n delivered: sends.every((send) => send.report.delivered === null)\n ? null\n : sends.reduce((sum, send) => sum + (send.report.delivered ?? 0), 0),\n openRate: pooled(sends.map((send) => send.report.rates.open)),\n clickRate: pooled(sends.map((send) => send.report.rates.click)),\n },\n ...sends.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1).map((send) => ({\n campaign: send.subject,\n delivered: send.report.delivered,\n openRate: percent(send.report.rates.open),\n clickRate: percent(send.report.rates.click),\n })),\n ]\n return {\n ok: true,\n table: {\n title: 'Campaigns',\n source: { label: 'Campaigns', path: 'marketing/campaigns' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'campaign', label: 'Campaign', kind: 'text' },\n { key: 'delivered', label: 'Delivered', kind: 'count' },\n { key: 'openRate', label: 'Opened', kind: 'percent' },\n { key: 'clickRate', label: 'Clicked', kind: 'percent' },\n ],\n rows,\n omitted: Math.max(0, sends.length - (PLUGIN_FIGURE_MAX_ROWS - 1)),\n notes: [\n 'Rates are over delivered messages, and a campaign with no delivery recorded has none.',\n ...(snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT\n ? [`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`]\n : []),\n ],\n },\n }\n },\n },\n {\n id: 'marketing.experiments',\n label: 'A/B tests',\n description:\n 'Each running or finished A/B test on the site: every variant’s visitors shown it, conversions and conversion rate, with the lift over the first variant and how confident that lift is.',\n scope: 'site',\n windows: [],\n feature: 'abTesting',\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its A/B tests' }\n const experimentsRef = firestore().collection('hosts').doc(request.hostId).collection('experiments')\n const snapshot = await experimentsRef.limit(EXPERIMENT_FIGURES_READ_LIMIT).get()\n const rows: PluginFigureRow[] = []\n for (const doc of snapshot.docs) {\n const experiment = (doc.data() ?? {}) as Partial<HostExperiment>\n if (experiment.status !== 'running' && experiment.status !== 'done') continue\n const variants = (experiment.variants ?? []).slice(0, 4)\n if (!variants.length) continue\n const stats = await doc.ref.collection('stats').get()\n const byVariant = new Map(stats.docs.map((entry) => [entry.id, entry.data() ?? {}]))\n const control = byVariant.get(variants[0].id) ?? {}\n variants.forEach((variant, index) => {\n const own = byVariant.get(variant.id) ?? {}\n const summary = summarizeVariantStats(own)\n const comparison = index === 0 ? null : compareVariants(control, own)\n rows.push({\n test: String(experiment.name ?? '').trim() || doc.id,\n variant: String(variant.name ?? '').trim() || variant.id,\n shown: summary.exposures,\n conversions: summary.conversions,\n rate: summary.exposures ? Math.round(summary.rate * 1_000) / 10 : null,\n lift: comparison?.lift === null || comparison === null ? null : Math.round(comparison.lift * 1_000) / 10,\n confidence:\n comparison?.confidence === null || comparison === null\n ? null\n : Math.round(comparison.confidence * 1_000) / 10,\n })\n })\n }\n return {\n ok: true,\n table: {\n title: 'A/B tests',\n source: { label: 'A/B testing', path: 'marketing/experiments' },\n period: null,\n columns: [\n { key: 'test', label: 'Test', kind: 'text' },\n { key: 'variant', label: 'Variant', kind: 'text' },\n { key: 'shown', label: 'Shown', kind: 'count' },\n { key: 'conversions', label: 'Conversions', kind: 'count' },\n { key: 'rate', label: 'Conversion rate', kind: 'percent' },\n { key: 'lift', label: 'Lift over the first variant', kind: 'change' },\n { key: 'confidence', label: 'Confidence in the lift', kind: 'percent' },\n ],\n rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),\n omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: ['Figures are totals since each test started; the first variant of a test is its control.'],\n },\n }\n },\n },\n ]\n}\n\n/** A stored count as a non-negative integer. */\nconst whole = (raw: unknown): number => {\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * What the site's campaigns CAUSED and EARNED, as two more tables (AGL-3603):\n * the conversions credited to a campaign touch in the window, and the revenue\n * credited to the campaign emails sent in it. Same rules the Conversions\n * section and a campaign's report print — the kinds are never added together,\n * currencies are never added together, net is gross less refunded — and the\n * same honesty about what is NOT here: a conversion credited to nothing has no\n * record, so it is not counted, and the notes say so.\n */\nexport function marketingOutcomeFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'marketing.conversions',\n label: 'Campaign conversions',\n description:\n 'Conversions credited to a campaign touch on the site in the window — form submissions, leads, contacts and bookings, one row each — counted by the touch that earned the credit: a campaign email, a page filed under a campaign, a link labeled for a campaign, or a sales sequence email.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its conversions' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one conversions are read over' }\n }\n const { current } = pluginFigureWindows(request.now, request.days)\n /*\n * Aggregation counts, never the records: per kind, one over every\n * channel and one per channel, each an equality on `kind` (and\n * `channel`) with the window on `convertedAtMs`. Served by the two\n * composite indexes the index file declares for them.\n */\n const inWindow = (kind: CampaignConversionKind, channel: CampaignTouchChannel | null) => {\n let query = firestore()\n .collection('hosts')\n .doc(request.hostId as string)\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .where('kind', '==', kind)\n if (channel) query = query.where('channel', '==', channel)\n return query\n .where('convertedAtMs', '>=', current.startMs)\n .where('convertedAtMs', '<', current.endMs)\n .count()\n .get()\n .then((snapshot) => whole(snapshot.data().count))\n }\n const rows: PluginFigureRow[] = await Promise.all(\n CAMPAIGN_CONVERSION_KINDS.map(async (kind) => {\n const [credited, ...byChannel] = await Promise.all([\n inWindow(kind, null),\n ...CONVERSION_FIGURE_CHANNELS.map((channel) => inWindow(kind, channel)),\n ])\n const row: PluginFigureRow = { kind: CONVERSION_KIND_LABELS[kind], credited }\n CONVERSION_FIGURE_CHANNELS.forEach((channel, index) => {\n row[channel] = byChannel[index]\n })\n return row\n }),\n )\n return {\n ok: true,\n table: {\n title: 'Campaign conversions',\n source: { label: 'Conversions', path: 'marketing/conversions' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'kind', label: 'Conversion', kind: 'text' },\n { key: 'credited', label: 'Credited to a campaign', kind: 'count' },\n { key: 'email', label: 'From a campaign email', kind: 'count' },\n { key: 'page', label: 'From a campaign page', kind: 'count' },\n { key: 'web', label: 'From a labeled link', kind: 'count' },\n { key: 'sequence', label: 'From a sequence email', kind: 'count' },\n ],\n rows,\n omitted: 0,\n notes: [\n 'Each row counts a different record of the same visits — one form submission can also make a lead and a contact — so the rows are never added together.',\n 'Only conversions credited to a campaign touch are counted; a conversion credited to nothing has no record and is not in this table.',\n ],\n },\n }\n },\n },\n {\n id: 'marketing.revenue',\n label: 'Campaign revenue',\n description:\n 'Revenue credited to each campaign email sent from the site in the window: orders credited, gross, refunded and net, one row per email and currency, with amounts in each row’s currency.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its campaign revenue' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one campaign revenue is read over' }\n }\n if (!request.orgId) return { ok: false, status: 400, error: 'This site is not part of an organization' }\n const { current } = pluginFigureWindows(request.now, request.days)\n // The campaigns table's own read: the site's sends, newest first.\n const sendsRef = orgCampaignSends(firestore(), request.orgId)\n const snapshot = await sendsRef\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId)\n .orderBy(EMAIL_CREATED_AT_FIELD, 'desc')\n .limit(CAMPAIGN_FIGURES_READ_LIMIT)\n .get()\n const sends = snapshot.docs\n .filter((doc) => {\n const record = doc.data() ?? {}\n const sentAt = emailSendTimeMs(record)\n return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs\n })\n .slice(0, PLUGIN_FIGURE_MAX_ROWS)\n /*\n * One keyed read per send for its revenue rollup, bounded by the rows\n * a table may carry — the rollup is its own document so the history\n * list never pays for it, and this is the one place that does.\n */\n const rollups = sends.length\n ? await firestore().getAll(\n ...sends.map((doc) => sendsRef.doc(doc.id).collection('reports').doc('revenue')),\n )\n : []\n const rows: PluginFigureRow[] = []\n const currencies = new Set<string>()\n sends.forEach((doc, index) => {\n const rollup = (rollups[index]?.data() ?? {}) as CampaignRevenueRollup\n for (const [currency, totals] of Object.entries(rollup.byCurrency ?? {})) {\n const grossCents = whole(totals?.grossCents)\n const refundedCents = whole(totals?.refundedCents)\n const orders = whole(totals?.orders)\n if (!orders && !grossCents) continue\n currencies.add(currency.toUpperCase())\n rows.push({\n campaign: String(doc.get('subject') ?? '').trim() || 'A campaign with no subject',\n currency: currency.toUpperCase(),\n orders,\n gross: grossCents / 100,\n refunded: refundedCents / 100,\n // Clamped where it is shown, as the campaign report clamps it.\n net: Math.max(0, grossCents - refundedCents) / 100,\n })\n }\n })\n const [only] = currencies\n const money = currencies.size === 1 && /^[A-Z]{3}$/.test(only ?? '')\n const amount = (key: string, label: string) =>\n money\n ? { key, label, kind: 'money' as const, currency: only }\n : { key, label: `${label} (in the row’s currency)`, kind: 'number' as const }\n return {\n ok: true,\n table: {\n title: 'Campaign revenue',\n source: { label: 'Campaigns', path: 'marketing/campaigns' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'campaign', label: 'Campaign email', kind: 'text' },\n { key: 'currency', label: 'Currency', kind: 'text' },\n { key: 'orders', label: 'Orders credited', kind: 'count' },\n amount('gross', 'Gross'),\n amount('refunded', 'Refunded'),\n amount('net', 'Net'),\n ],\n rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),\n omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: [\n `Revenue is credited to the last campaign email whose link the buyer clicked within ${EMAIL_ATTRIBUTION_WINDOW_DAYS} days, for the emails sent in the window; amounts in different currencies are never added together.`,\n ...(snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT\n ? [`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`]\n : []),\n ],\n },\n }\n },\n },\n ]\n}\n\n/** Registers the campaign and A/B testing readers from the console surface, where insight jobs run. */\nexport function registerMarketingFigureReaders(firestore: () => Firestore): void {\n for (const reader of [...marketingFigureReaders(firestore), ...marketingOutcomeFigureReaders(firestore)]) {\n registerPluginFigureReader(reader, { pluginId: MARKETING_PLUGIN_ID })\n }\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","pluginFigureWindows","registerPluginFigureReader","campaignReport","EMAIL_CREATED_AT_FIELD","emailSendTimeMs","CAMPAIGN_SEND_HOST_FIELD","orgCampaignSends","compareVariants","summarizeVariantStats","CAMPAIGN_CONVERSION_KINDS","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_ATTRIBUTIONS_COLLECTION","MARKETING_PLUGIN_ID","CAMPAIGN_FIGURES_READ_LIMIT","EXPERIMENT_FIGURES_READ_LIMIT","CONVERSION_FIGURE_CHANNELS","CONVERSION_KIND_LABELS","form","lead","contact","booking","WINDOWS","percent","rate","Math","round","value","pooled","rates","known","filter","denominator","reduce","sum","numerator","marketingFigureReaders","firestore","id","label","description","scope","windows","read","request","hostId","ok","status","error","includes","days","orgId","current","now","snapshot","where","orderBy","limit","get","sends","docs","map","doc","data","record","sentAt","Boolean","startMs","endMs","subject","String","trim","report","rows","campaign","delivered","every","send","openRate","open","clickRate","click","slice","table","title","source","path","period","from","to","columns","key","kind","omitted","max","length","notes","feature","experimentsRef","collection","experiment","byVariant","variants","stats","ref","Map","entry","control","forEach","variant","index","own","summary","comparison","push","test","name","shown","exposures","conversions","lift","confidence","whole","raw","Number","isFinite","floor","marketingOutcomeFigureReaders","inWindow","channel","query","count","then","Promise","all","credited","byChannel","row","sendsRef","rollups","getAll","currencies","Set","rollup","currency","totals","Object","entries","byCurrency","grossCents","refundedCents","orders","add","toUpperCase","gross","refunded","net","only","money","size","amount","registerMarketingFigureReaders","reader","pluginId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/marketing-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRow,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { figureCount, figureRow, figureStat } from '@aglyn/aglyn/app-utils/weekly-summary'\nimport {\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { campaignReport } from '../model/campaign-report'\nimport {\n EMAIL_CREATED_AT_FIELD,\n emailSendTimeMs,\n} from '../model/email-record'\nimport { CAMPAIGN_SEND_HOST_FIELD } from '../model/campaign-container'\nimport { orgCampaignSends } from './campaign-org-refs'\nimport { compareVariants, summarizeVariantStats, type HostExperiment } from '../model/experiments'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n type CampaignConversionKind,\n type CampaignTouchChannel,\n} from '../model/campaign-conversions'\nimport {\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n type CampaignRevenueRollup,\n} from '../model/campaign-revenue'\nimport { CAMPAIGN_ATTRIBUTIONS_COLLECTION } from './campaign-attribution-store'\n\n/**\n * A site's campaign and A/B testing results as figure tables another plugin\n * reads by id (AGL-2915).\n *\n * The campaign table is what each send's report shows — delivered, and the\n * open and click rates over delivered — through `campaignReport`, the one\n * reader of a send's counters. The experiments table is what the A/B testing\n * section shows, through the same variant comparison. No recipient, address\n * or visitor reaches either: a campaign is named by its subject, a variant by\n * its name.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** Sends a campaign table reads, newest first, before it keeps those sent in the window. */\nexport const CAMPAIGN_FIGURES_READ_LIMIT = 100\n\n/** Experiments an experiments table reads. */\nexport const EXPERIMENT_FIGURES_READ_LIMIT = 20\n\n/**\n * The channels a conversion is counted by, in reading order. Each is an\n * aggregation count over the attribution records — `kind`, `channel` and the\n * window on `convertedAtMs` — so the table costs a read per thousand index\n * entries rather than a read per record, and never loads one.\n */\nexport const CONVERSION_FIGURE_CHANNELS: readonly CampaignTouchChannel[] = ['email', 'page', 'web', 'sequence']\n\n/** How a conversions table names each kind: the record it credits, in the console's words. */\nconst CONVERSION_KIND_LABELS: Readonly<Record<CampaignConversionKind, string>> = {\n form: 'Form submissions',\n lead: 'Leads',\n contact: 'Contacts',\n booking: 'Bookings',\n}\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\nconst percent = (rate: SendRate | null): number | null =>\n rate ? Math.round(rate.value * 1_000) / 10 : null\n\n/** Rates pooled across sends: every numerator over every denominator, never an average of rates. */\nfunction pooled(rates: ReadonlyArray<SendRate | null>): number | null {\n const known = rates.filter((rate): rate is SendRate => rate !== null)\n const denominator = known.reduce((sum, rate) => sum + rate.denominator, 0)\n if (!denominator) return null\n return Math.round((known.reduce((sum, rate) => sum + rate.numerator, 0) / denominator) * 1_000) / 10\n}\n\nexport function marketingFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'marketing.campaigns',\n label: 'Campaigns',\n description:\n 'Each campaign email sent from the site in the window: how many were delivered and the share of delivered messages opened and clicked, with every campaign together on the first row.',\n scope: 'site',\n windows: WINDOWS,\n weeklySummary: {\n order: 90,\n summarize: (table) => {\n const all = figureRow(table, 'campaign', 'All campaigns')\n if (!all || !figureCount(all['delivered'])) return null\n const sent = table.rows.filter((row) => row !== all && figureCount(row['delivered']) > 0)\n return {\n title: 'Campaigns',\n stats: [\n { label: sent.length === 1 ? 'Email sent' : 'Emails sent', value: sent.length.toLocaleString('en-US') },\n figureStat(table, all, 'delivered', 'Delivered'),\n ...(typeof all['openRate'] === 'number' ? [figureStat(table, all, 'openRate', 'Opened')] : []),\n ...(typeof all['clickRate'] === 'number' ? [figureStat(table, all, 'clickRate', 'Clicked')] : []),\n ],\n }\n },\n },\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its campaigns' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one campaigns are read over' }\n }\n if (!request.orgId) return { ok: false, status: 400, error: 'This site is not part of an organization' }\n const { current } = pluginFigureWindows(request.now, request.days)\n /*\n * The organization's sends, narrowed to the ones sent as this site.\n * Served by the composite index on the site field and the creation\n * stamp — the equality and the ordering together are one index.\n */\n const snapshot = await orgCampaignSends(firestore(), request.orgId)\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId)\n .orderBy(EMAIL_CREATED_AT_FIELD, 'desc')\n .limit(CAMPAIGN_FIGURES_READ_LIMIT)\n .get()\n const sends = snapshot.docs\n .map((doc) => doc.data() ?? {})\n .filter((record) => {\n const sentAt = emailSendTimeMs(record)\n return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs\n })\n .map((record) => ({\n subject: String(record['subject'] ?? '').trim() || 'A campaign with no subject',\n report: campaignReport(record['stats'] as SendStats | undefined),\n }))\n const rows: PluginFigureRow[] = [\n {\n campaign: 'All campaigns',\n delivered: sends.every((send) => send.report.delivered === null)\n ? null\n : sends.reduce((sum, send) => sum + (send.report.delivered ?? 0), 0),\n openRate: pooled(sends.map((send) => send.report.rates.open)),\n clickRate: pooled(sends.map((send) => send.report.rates.click)),\n },\n ...sends.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1).map((send) => ({\n campaign: send.subject,\n delivered: send.report.delivered,\n openRate: percent(send.report.rates.open),\n clickRate: percent(send.report.rates.click),\n })),\n ]\n return {\n ok: true,\n table: {\n title: 'Campaigns',\n source: { label: 'Campaigns', path: 'marketing/campaigns' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'campaign', label: 'Campaign', kind: 'text' },\n { key: 'delivered', label: 'Delivered', kind: 'count' },\n { key: 'openRate', label: 'Opened', kind: 'percent' },\n { key: 'clickRate', label: 'Clicked', kind: 'percent' },\n ],\n rows,\n omitted: Math.max(0, sends.length - (PLUGIN_FIGURE_MAX_ROWS - 1)),\n notes: [\n 'Rates are over delivered messages, and a campaign with no delivery recorded has none.',\n ...(snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT\n ? [`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`]\n : []),\n ],\n },\n }\n },\n },\n {\n id: 'marketing.experiments',\n label: 'A/B tests',\n description:\n 'Each running or finished A/B test on the site: every variant’s visitors shown it, conversions and conversion rate, with the lift over the first variant and how confident that lift is.',\n scope: 'site',\n windows: [],\n feature: 'abTesting',\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its A/B tests' }\n const experimentsRef = firestore().collection('hosts').doc(request.hostId).collection('experiments')\n const snapshot = await experimentsRef.limit(EXPERIMENT_FIGURES_READ_LIMIT).get()\n const rows: PluginFigureRow[] = []\n for (const doc of snapshot.docs) {\n const experiment = (doc.data() ?? {}) as Partial<HostExperiment>\n if (experiment.status !== 'running' && experiment.status !== 'done') continue\n const variants = (experiment.variants ?? []).slice(0, 4)\n if (!variants.length) continue\n const stats = await doc.ref.collection('stats').get()\n const byVariant = new Map(stats.docs.map((entry) => [entry.id, entry.data() ?? {}]))\n const control = byVariant.get(variants[0].id) ?? {}\n variants.forEach((variant, index) => {\n const own = byVariant.get(variant.id) ?? {}\n const summary = summarizeVariantStats(own)\n const comparison = index === 0 ? null : compareVariants(control, own)\n rows.push({\n test: String(experiment.name ?? '').trim() || doc.id,\n variant: String(variant.name ?? '').trim() || variant.id,\n shown: summary.exposures,\n conversions: summary.conversions,\n rate: summary.exposures ? Math.round(summary.rate * 1_000) / 10 : null,\n lift: comparison?.lift === null || comparison === null ? null : Math.round(comparison.lift * 1_000) / 10,\n confidence:\n comparison?.confidence === null || comparison === null\n ? null\n : Math.round(comparison.confidence * 1_000) / 10,\n })\n })\n }\n return {\n ok: true,\n table: {\n title: 'A/B tests',\n source: { label: 'A/B testing', path: 'marketing/experiments' },\n period: null,\n columns: [\n { key: 'test', label: 'Test', kind: 'text' },\n { key: 'variant', label: 'Variant', kind: 'text' },\n { key: 'shown', label: 'Shown', kind: 'count' },\n { key: 'conversions', label: 'Conversions', kind: 'count' },\n { key: 'rate', label: 'Conversion rate', kind: 'percent' },\n { key: 'lift', label: 'Lift over the first variant', kind: 'change' },\n { key: 'confidence', label: 'Confidence in the lift', kind: 'percent' },\n ],\n rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),\n omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: ['Figures are totals since each test started; the first variant of a test is its control.'],\n },\n }\n },\n },\n ]\n}\n\n/** A stored count as a non-negative integer. */\nconst whole = (raw: unknown): number => {\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * What the site's campaigns CAUSED and EARNED, as two more tables (AGL-3603):\n * the conversions credited to a campaign touch in the window, and the revenue\n * credited to the campaign emails sent in it. Same rules the Conversions\n * section and a campaign's report print — the kinds are never added together,\n * currencies are never added together, net is gross less refunded — and the\n * same honesty about what is NOT here: a conversion credited to nothing has no\n * record, so it is not counted, and the notes say so.\n */\nexport function marketingOutcomeFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'marketing.conversions',\n label: 'Campaign conversions',\n description:\n 'Conversions credited to a campaign touch on the site in the window — form submissions, leads, contacts and bookings, one row each — counted by the touch that earned the credit: a campaign email, a page filed under a campaign, a link labeled for a campaign, or a sales sequence email.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its conversions' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one conversions are read over' }\n }\n const { current } = pluginFigureWindows(request.now, request.days)\n /*\n * Aggregation counts, never the records: per kind, one over every\n * channel and one per channel, each an equality on `kind` (and\n * `channel`) with the window on `convertedAtMs`. Served by the two\n * composite indexes the index file declares for them.\n */\n const inWindow = (kind: CampaignConversionKind, channel: CampaignTouchChannel | null) => {\n let query = firestore()\n .collection('hosts')\n .doc(request.hostId as string)\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .where('kind', '==', kind)\n if (channel) query = query.where('channel', '==', channel)\n return query\n .where('convertedAtMs', '>=', current.startMs)\n .where('convertedAtMs', '<', current.endMs)\n .count()\n .get()\n .then((snapshot) => whole(snapshot.data().count))\n }\n const rows: PluginFigureRow[] = await Promise.all(\n CAMPAIGN_CONVERSION_KINDS.map(async (kind) => {\n const [credited, ...byChannel] = await Promise.all([\n inWindow(kind, null),\n ...CONVERSION_FIGURE_CHANNELS.map((channel) => inWindow(kind, channel)),\n ])\n const row: PluginFigureRow = { kind: CONVERSION_KIND_LABELS[kind], credited }\n CONVERSION_FIGURE_CHANNELS.forEach((channel, index) => {\n row[channel] = byChannel[index]\n })\n return row\n }),\n )\n return {\n ok: true,\n table: {\n title: 'Campaign conversions',\n source: { label: 'Conversions', path: 'marketing/conversions' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'kind', label: 'Conversion', kind: 'text' },\n { key: 'credited', label: 'Credited to a campaign', kind: 'count' },\n { key: 'email', label: 'From a campaign email', kind: 'count' },\n { key: 'page', label: 'From a campaign page', kind: 'count' },\n { key: 'web', label: 'From a labeled link', kind: 'count' },\n { key: 'sequence', label: 'From a sequence email', kind: 'count' },\n ],\n rows,\n omitted: 0,\n notes: [\n 'Each row counts a different record of the same visits — one form submission can also make a lead and a contact — so the rows are never added together.',\n 'Only conversions credited to a campaign touch are counted; a conversion credited to nothing has no record and is not in this table.',\n ],\n },\n }\n },\n },\n {\n id: 'marketing.revenue',\n label: 'Campaign revenue',\n description:\n 'Revenue credited to each campaign email sent from the site in the window: orders credited, gross, refunded and net, one row per email and currency, with amounts in each row’s currency.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its campaign revenue' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one campaign revenue is read over' }\n }\n if (!request.orgId) return { ok: false, status: 400, error: 'This site is not part of an organization' }\n const { current } = pluginFigureWindows(request.now, request.days)\n // The campaigns table's own read: the site's sends, newest first.\n const sendsRef = orgCampaignSends(firestore(), request.orgId)\n const snapshot = await sendsRef\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId)\n .orderBy(EMAIL_CREATED_AT_FIELD, 'desc')\n .limit(CAMPAIGN_FIGURES_READ_LIMIT)\n .get()\n const sends = snapshot.docs\n .filter((doc) => {\n const record = doc.data() ?? {}\n const sentAt = emailSendTimeMs(record)\n return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs\n })\n .slice(0, PLUGIN_FIGURE_MAX_ROWS)\n /*\n * One keyed read per send for its revenue rollup, bounded by the rows\n * a table may carry — the rollup is its own document so the history\n * list never pays for it, and this is the one place that does.\n */\n const rollups = sends.length\n ? await firestore().getAll(\n ...sends.map((doc) => sendsRef.doc(doc.id).collection('reports').doc('revenue')),\n )\n : []\n const rows: PluginFigureRow[] = []\n const currencies = new Set<string>()\n sends.forEach((doc, index) => {\n const rollup = (rollups[index]?.data() ?? {}) as CampaignRevenueRollup\n for (const [currency, totals] of Object.entries(rollup.byCurrency ?? {})) {\n const grossCents = whole(totals?.grossCents)\n const refundedCents = whole(totals?.refundedCents)\n const orders = whole(totals?.orders)\n if (!orders && !grossCents) continue\n currencies.add(currency.toUpperCase())\n rows.push({\n campaign: String(doc.get('subject') ?? '').trim() || 'A campaign with no subject',\n currency: currency.toUpperCase(),\n orders,\n gross: grossCents / 100,\n refunded: refundedCents / 100,\n // Clamped where it is shown, as the campaign report clamps it.\n net: Math.max(0, grossCents - refundedCents) / 100,\n })\n }\n })\n const [only] = currencies\n const money = currencies.size === 1 && /^[A-Z]{3}$/.test(only ?? '')\n const amount = (key: string, label: string) =>\n money\n ? { key, label, kind: 'money' as const, currency: only }\n : { key, label: `${label} (in the row’s currency)`, kind: 'number' as const }\n return {\n ok: true,\n table: {\n title: 'Campaign revenue',\n source: { label: 'Campaigns', path: 'marketing/campaigns' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'campaign', label: 'Campaign email', kind: 'text' },\n { key: 'currency', label: 'Currency', kind: 'text' },\n { key: 'orders', label: 'Orders credited', kind: 'count' },\n amount('gross', 'Gross'),\n amount('refunded', 'Refunded'),\n amount('net', 'Net'),\n ],\n rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),\n omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: [\n `Revenue is credited to the last campaign email whose link the buyer clicked within ${EMAIL_ATTRIBUTION_WINDOW_DAYS} days, for the emails sent in the window; amounts in different currencies are never added together.`,\n ...(snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT\n ? [`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`]\n : []),\n ],\n },\n }\n },\n },\n ]\n}\n\n/** Registers the campaign and A/B testing readers from the console surface, where insight jobs run. */\nexport function registerMarketingFigureReaders(firestore: () => Firestore): void {\n for (const reader of [...marketingFigureReaders(firestore), ...marketingOutcomeFigureReaders(firestore)]) {\n registerPluginFigureReader(reader, { pluginId: MARKETING_PLUGIN_ID })\n }\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","pluginFigureWindows","registerPluginFigureReader","figureCount","figureRow","figureStat","campaignReport","EMAIL_CREATED_AT_FIELD","emailSendTimeMs","CAMPAIGN_SEND_HOST_FIELD","orgCampaignSends","compareVariants","summarizeVariantStats","CAMPAIGN_CONVERSION_KINDS","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_ATTRIBUTIONS_COLLECTION","MARKETING_PLUGIN_ID","CAMPAIGN_FIGURES_READ_LIMIT","EXPERIMENT_FIGURES_READ_LIMIT","CONVERSION_FIGURE_CHANNELS","CONVERSION_KIND_LABELS","form","lead","contact","booking","WINDOWS","percent","rate","Math","round","value","pooled","rates","known","filter","denominator","reduce","sum","numerator","marketingFigureReaders","firestore","id","label","description","scope","windows","weeklySummary","order","summarize","table","all","sent","rows","row","title","stats","length","toLocaleString","read","request","hostId","ok","status","error","includes","days","orgId","current","now","snapshot","where","orderBy","limit","get","sends","docs","map","doc","data","record","sentAt","Boolean","startMs","endMs","subject","String","trim","report","campaign","delivered","every","send","openRate","open","clickRate","click","slice","source","path","period","from","to","columns","key","kind","omitted","max","notes","feature","experimentsRef","collection","experiment","byVariant","variants","ref","Map","entry","control","forEach","variant","index","own","summary","comparison","push","test","name","shown","exposures","conversions","lift","confidence","whole","raw","Number","isFinite","floor","marketingOutcomeFigureReaders","inWindow","channel","query","count","then","Promise","credited","byChannel","sendsRef","rollups","getAll","currencies","Set","rollup","currency","totals","Object","entries","byCurrency","grossCents","refundedCents","orders","add","toUpperCase","gross","refunded","net","only","money","size","amount","registerMarketingFigureReaders","reader","pluginId"],"mappings":"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"}
|