@aglyn/plugins-marketing 1.0.0-beta.161 → 1.0.0-beta.163
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 +15 -15
- package/src/lib/components/campaign-detail-card.js +6 -1
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-reach-sections.d.ts +21 -0
- package/src/lib/components/campaign-reach-sections.js +82 -1
- package/src/lib/components/campaign-reach-sections.js.map +1 -1
- package/src/lib/components/conversion-attribution.component.d.ts +4 -2
- package/src/lib/components/conversion-attribution.component.js +6 -5
- package/src/lib/components/conversion-attribution.component.js.map +1 -1
- package/src/lib/server/campaign-manage.js +26 -1
- package/src/lib/server/campaign-manage.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.163",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,20 +25,20 @@
|
|
|
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/tenant-data-admin": "1.0.0-beta.
|
|
40
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
41
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.163",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.163",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.163",
|
|
31
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.163",
|
|
32
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.163",
|
|
33
|
+
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.163",
|
|
34
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.163",
|
|
35
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.163",
|
|
36
|
+
"@aglyn/shared-util-email": "1.0.0-beta.163",
|
|
37
|
+
"@aglyn/shared-util-http": "1.0.0-beta.163",
|
|
38
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.163",
|
|
39
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.163",
|
|
40
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.163",
|
|
41
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.163",
|
|
42
42
|
"@swc/helpers": "0.5.23"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
@@ -33,7 +33,7 @@ import { useFirestore, useFirestoreCollection, useFirestoreDoc, useOrgDataScope
|
|
|
33
33
|
import { campaignRollup, campaignSendDisplay, campaignWindowState, emailListTimeMs } from "@aglyn/shared-ui-email-campaigns/model";
|
|
34
34
|
import CampaignComposer from "./campaign-composer.js";
|
|
35
35
|
import CampaignEditDrawer from "./campaign-edit-drawer.js";
|
|
36
|
-
import { CampaignConversionsSection, CampaignDestinationsSection, CampaignRevenueSection } from "./campaign-reach-sections.js";
|
|
36
|
+
import { CampaignConversionsSection, CampaignDestinationsSection, CampaignRevenueSection, CampaignSequencesSection } from "./campaign-reach-sections.js";
|
|
37
37
|
import CampaignMembersSection from "./campaign-members-section.js";
|
|
38
38
|
import CampaignReportCard from "./campaign-report-card.js";
|
|
39
39
|
import { useCampaignManageApi } from "./use-campaign-send-api.js";
|
|
@@ -608,6 +608,11 @@ const detailDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
608
608
|
basePath: basePath
|
|
609
609
|
}),
|
|
610
610
|
/*#__PURE__*/ _jsx(Divider, {}),
|
|
611
|
+
/*#__PURE__*/ _jsx(CampaignSequencesSection, {
|
|
612
|
+
hostId: hostId,
|
|
613
|
+
campaignId: campaignId
|
|
614
|
+
}),
|
|
615
|
+
/*#__PURE__*/ _jsx(Divider, {}),
|
|
611
616
|
/*#__PURE__*/ _jsx(CampaignRevenueSection, {
|
|
612
617
|
hostId: hostId,
|
|
613
618
|
sendIds: sendIds,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-detail-card.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 {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPaletteOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n documentId,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignRollup,\n campaignSendDisplay,\n campaignWindowState,\n emailListTimeMs,\n type CampaignAggregate,\n type CampaignSend,\n type EmailCampaign,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport CampaignComposer from './campaign-composer'\nimport CampaignEditDrawer, {\n type CampaignEditValues,\n} from './campaign-edit-drawer'\nimport {\n CampaignConversionsSection,\n CampaignDestinationsSection,\n CampaignRevenueSection,\n} from './campaign-reach-sections'\nimport CampaignMembersSection from './campaign-members-section'\nimport CampaignReportCard from './campaign-report-card'\nimport { useCampaignManageApi } from './use-campaign-send-api'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport { useCampaignTopicOptions } from './use-campaign-topic-options'\n\n/** How many of a campaign's emails the detail page enumerates. */\nconst CAMPAIGN_EMAIL_CEILING = 50\n\nconst detailDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A campaign groups the emails sent to its lists. The figures here are ' +\n 'the sum across those emails, and each one keeps its own report.',\n})\n\n/**\n * A rolled-up figure, in the shared `Figure`'s shape.\n *\n * The note is where an aggregate differs from a single send's count: a total\n * measured over fewer emails than the campaign holds says which part of the\n * campaign it describes, rather than presenting a partial sum as a complete\n * one. `null` still renders as an em dash and says \"not recorded\", which is\n * the property the shared component already owns.\n */\nconst rolled = (\n value: CampaignAggregate,\n): { value: number | null; note: string } => ({\n value: value.value,\n note:\n value.value !== null && value.recorded < value.sends\n ? `across ${value.recorded} of ${value.sends} emails`\n : `across ${value.sends} email${value.sends === 1 ? '' : 's'}`,\n})\n\nexport interface CampaignDetailCardProps {\n hostId: string\n /** A campaign container id, or a send id from before containers existed. */\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN: its lists, what it caused, what it earned, where it sent\n * people, and the emails it did all of that with.\n *\n * ## Why the emails are a section rather than the page\n *\n * A campaign reaches people by email, which is not the same as a campaign\n * BEING a list of emails. It runs over a window, against a set of audiences,\n * and the pages its links land on, the money credited to it and the\n * conversions credited to it are facts about the campaign that no single\n * message in it holds. So the body is sectioned — what it caused, what it\n * earned, where it sent people, then the mail's own figures, then its emails\n * — and each section names the population it describes.\n *\n * The ORDER is the part that says which of those the page is about. Delivery,\n * engagement and rates are mechanics of a message; a page that met the reader\n * with them and reached the outcomes only after scrolling was a mail report\n * whatever its headings claimed. `campaign-report-card.tsx` — the page a\n * campaign of one email gets — carries the same two outcome headings first,\n * for the same reason and in the same words.\n *\n * The three middle sections live in `campaign-reach-sections.tsx`, which is\n * also where the reason a campaign cannot simply DECLARE the screens and\n * forms it runs across is written down: no such edge is stored, attribution\n * is the mechanism, and all three sections join on this campaign's send ids.\n *\n * ## Why this resolves two kinds of id\n *\n * The id in `campaigns/{id}` may name a campaign CONTAINER or a single SEND,\n * and which it is is answered by reading: a container at `emailCampaigns/{id}`\n * renders this page, and anything else falls through to that send's own\n * report.\n *\n * Not every send belongs to a container. One written before campaigns grouped\n * their emails names none, its own id is the only id the URL can carry, and\n * that id is also inside the HMAC of every unsubscribe footer already\n * delivered — so a send id has to go on addressing a page rather than a 404.\n * The fall-through is what makes the container additive: no send document is\n * rewritten and no id is reassigned.\n *\n * The extra read is one document, and it buys the guarantee that a pasted\n * report link resolves whichever kind of id it carries.\n */\nexport function CampaignDetailCard(props: CampaignDetailCardProps) {\n const { hostId, campaignId, basePath } = props\n const firestore = useFirestore()\n // The sibling hub, for the two records this page links to but does not own:\n // each message's report and the template it was built from.\n const emailsHub = useEmailsHubPath()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n const manageApi = useCampaignManageApi(hostId)\n const [composing, setComposing] = useState(false)\n const [editing, setEditing] = useState(false)\n const [saving, setSaving] = useState(false)\n const [saveError, setSaveError] = useState<string | null>(null)\n const [deleting, setDeleting] = useState(false)\n\n const { data: campaign, status } = useFirestoreDoc<EmailCampaign>(\n () => doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n [firestore, hostId, campaignId],\n )\n\n /*\n * ONLY WHEN THE ID NAMES A CAMPAIGN.\n *\n * Both queries below are gated on the container having been found, because\n * the other half of this component is the single-send report — and that\n * screen's whole design is that it reads two documents whatever the size of\n * the audience. Listening unconditionally would add two collection reads to\n * every legacy report URL, which is the cost the report route was split out\n * to avoid. Not listening is what a null builder means.\n *\n * The campaign's emails come back by the field each send carries: equality\n * on one field ordered by DOCUMENT NAME, which Firestore's automatic\n * single-field index serves without a composite one. Ordering on a date\n * would need that index AND would drop every send missing that particular\n * date — a sent send carries `sentAt`, a scheduled one `sendAtMs`, and\n * neither is on both. Sorted by date below, over a window this page holds\n * whole.\n *\n * One document past the ceiling, so \"this campaign has more emails than are\n * listed\" is a fact rather than a guess.\n */\n const { data: sendDocs } = useFirestoreCollection<any>(\n () =>\n campaign\n ? query(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n where('emailCampaignId', '==', campaignId),\n orderBy(documentId()),\n limit(CAMPAIGN_EMAIL_CEILING + 1),\n )\n : null,\n [firestore, hostId, campaignId, Boolean(campaign)],\n { idField: '$id' },\n )\n\n const { data: listDocs } = useFirestoreCollection<any>(\n () =>\n campaign && dataScope\n ? query(\n collection(firestore, dataScope[0], dataScope[1], 'lists'),\n limit(50),\n )\n : null,\n [firestore, dataScope, Boolean(campaign)],\n { idField: '$id' },\n )\n\n /*\n * The org's topic catalog, read only while the EDIT DRAWER is open.\n *\n * It fills one picker in that drawer and is drawn nowhere else on this\n * page, so reading it on mount would charge every reader who came for the\n * campaign's numbers for a field they are not looking at. The composer\n * carries its own read of the same catalog, behind its own button, for the\n * same reason.\n */\n const { topics, source: topicSource } = useCampaignTopicOptions(hostId, {\n enabled: editing,\n })\n const topicOptions = useMemo(\n () =>\n topics.map((topic) => ({\n value: topic.id,\n label: topic.name,\n })),\n [topics],\n )\n\n const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow<any>(\n sendDocs,\n CAMPAIGN_EMAIL_CEILING,\n )\n /*\n * Newest first on the time each email SITS at — its send time where it has\n * one, its creation where it does not. A draft has neither `sentAt` nor\n * `sendAtMs`, so ordering on the send time alone gave every draft the key 0\n * and filed the email a merchant is in the middle of writing below mail\n * sent years ago.\n */\n const sends = useMemo(\n () =>\n [...(readSends as CampaignSend[])].sort(\n (a, b) => emailListTimeMs(b) - emailListTimeMs(a),\n ),\n [readSends],\n )\n const rollup = useMemo(() => campaignRollup(sends), [sends])\n // The ids the two sections beneath the figures join on. Derived from the\n // window this card already holds, so neither of them reads the send list\n // again to find out which emails the campaign has.\n const sendIds = useMemo(\n () => sends.map((send) => String(send.$id)).filter(Boolean),\n [sends],\n )\n\n /*==========================================\n * SAVING THE CONTAINER, WITH THE CLIENT SDK.\n *\n * The same door the create drawer already uses. `emailCampaigns` is\n * deliberately outside the security rules' server-only exclusion list — a\n * container holds no counter, no consent record and no entitlement input,\n * so an editor who writes one cannot buy themselves a send — and giving the\n * edit a route of its own would be a second writer to keep in step with the\n * create.\n *\n * `updateDoc` rather than a merge-set, because this is an edit of a\n * document that exists: a set would CREATE a campaign at this id for\n * somebody who deleted it in another tab, which is the shape the delivery\n * webhook is careful about on the send collection for the same reason.\n *\n * A cleared date is written as `null` — the absence the model already\n * spells — and a cleared topic REMOVES the field, because the model has no\n * null topic and `campaignTopicId` is handed to the composer as one.\n *=========================================*/\n const handleSave = useCallback(\n async (values: CampaignEditValues) => {\n if (saving) return\n setSaving(true)\n setSaveError(null)\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n {\n name: values.name,\n startAtMs: values.startAtMs,\n endAtMs: values.endAtMs,\n listIds: values.listIds,\n topicId: values.topicId ? values.topicId : deleteField(),\n },\n )\n setEditing(false)\n enqueueSnackbar('Campaign updated', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n setSaveError('The campaign could not be saved')\n } finally {\n setSaving(false)\n }\n },\n [campaignId, enqueueSnackbar, firestore, hostId, saving],\n )\n\n /*==========================================\n * DELETING THE CONTAINER, WHICH IS NOT DELETING ITS MAIL.\n *\n * The route detaches every email first and then removes the campaign, so\n * what a merchant is agreeing to is the grouping going away — the emails\n * stay, keep their ids, keep their reports, and read afterwards as single\n * sends. Their unsubscribe links carry those ids inside an HMAC and go on\n * working, which is why the container can never take them with it.\n *\n * The confirmation says the count and says what a delete does NOT do. A\n * scheduled email inside the campaign still goes out at its time — stopping\n * somebody's mail is `cancel`, on that email's own page — and a merchant\n * who reads \"delete campaign\" as \"stop the campaign\" has to be told\n * otherwise before they press it, not after.\n *=========================================*/\n const handleDelete = useCallback(async () => {\n if (deleting) return\n /*\n * Emails with mail still to go: the ones waiting for their time AND the\n * ones part way through an audience larger than one batch. Both are what\n * a merchant needs warning about, and counting only `scheduled` would\n * miss the case that needs it most — a campaign that is delivering right\n * now is stored as `scheduled` too, but `rollup.scheduled` is the\n * narrower reading that excludes it.\n */\n const unfinished = rollup.scheduled + rollup.sending\n const agreed = await confirm({\n title: 'Delete this campaign?',\n description:\n (sends.length\n ? `The ${sends.length.toLocaleString()} ` +\n `${sends.length === 1 ? 'email' : 'emails'} in it are kept — ` +\n 'each keeps its own report and its unsubscribe links, and they ' +\n 'appear in the campaigns list as single sends. '\n : 'It holds no emails. ') +\n (unfinished\n ? `${unfinished.toLocaleString()} of them ${\n unfinished === 1 ? 'is' : 'are'\n } still going out or still due, and deleting the campaign does ` +\n 'not stop that — the sender picks each one up from its own ' +\n 'record. Stop a send from its own page. '\n : '') +\n 'Any screens, forms and contacts assigned to it come out of it and ' +\n 'are otherwise untouched. Only the campaign itself goes.',\n confirmationText: 'Delete campaign',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDeleting(true)\n try {\n const { response, payload } = await manageApi({\n action: 'deleteCampaign',\n campaignId,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The campaign could not be deleted',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Campaign deleted', { variant: 'success', persist: false })\n router.push(`${basePath}/campaigns`)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The campaign could not be deleted', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDeleting(false)\n }\n }, [\n basePath,\n campaignId,\n confirm,\n deleting,\n enqueueSnackbar,\n manageApi,\n rollup.scheduled,\n rollup.sending,\n router,\n sends.length,\n ])\n\n // Still settling. Falling through to the send report here would flash \"this\n // campaign could not be loaded\" on every open of a campaign that exists.\n if (!campaign && status === 'loading') return null\n\n /*\n * NOT A CONTAINER — so it is a send id, and the send's own report is what\n * this URL has always meant.\n */\n if (!campaign) {\n return (\n <CampaignReportCard\n hostId={hostId}\n campaignId={campaignId}\n basePath={basePath}\n />\n )\n }\n\n const listIds = campaign.listIds ?? []\n const names = new Map<string, string>(\n (listDocs ?? []).map((list: any) => [\n String(list.$id),\n String(list.name ?? list.$id),\n ]),\n )\n /*==========================================\n * ONE EMAIL'S PAGE — the same one every other route to that record opens.\n *\n * The Emails hub's `emails/{sendId}`, which is where the messages list and\n * a template's messages table also send a reader. One record has one page:\n * a link that resolved somewhere else depending on which list it was\n * clicked in would give the same message two pages, and the reader would\n * have no way to tell which one they were on.\n *\n * `campaigns/{sendId}` on THIS hub goes on resolving as well, through the\n * fall-through at the top of this file. That is not a convenience — every\n * unsubscribe footer already delivered carries `cid={sendId}`, those\n * messages sit in inboxes forever, and merchants paste report URLs into\n * their own mail. Which link the console GENERATES and which URLs ANSWER\n * are two separate questions, and only the first is decided here.\n *\n * Nor is a redirect the answer to the second. A redirect would be another\n * thing to be wrong about an id that is inside an HMAC; a page that keeps\n * working has nothing to get wrong, and it costs nothing to leave standing.\n *=========================================*/\n const sendHref = (send: CampaignSend) =>\n emailsHub ? `${emailsHub}/messages/${send.$id}` : undefined\n\n /**\n * What one of this campaign's emails can be opened into.\n *\n * The same two the Emails hub offers, less the campaign — this page IS the\n * campaign. Both destinations are records the Emails console owns, so they\n * are built from {@link useEmailsHubPath} rather than from this surface's\n * own `basePath`.\n *\n * A message composed inline was built from no template, so that entry is\n * shown DISABLED with the reason rather than hidden: an absent control and\n * an inapplicable one look identical, and only one is honest.\n */\n const sendActions = (send: CampaignSend): RowActionsMenuItem[] => {\n const templateScreenId = String((send as any).templateScreenId ?? '')\n return [\n {\n key: 'details',\n label: 'Open report',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: sendHref(send),\n disabled: !sendHref(send),\n disabledReason: 'This site’s console URL has not resolved yet',\n },\n {\n key: 'template',\n label: 'Open its template',\n icon: <MdiIcon path={mdiPaletteOutline.path} size={0.8} />,\n href:\n templateScreenId && emailsHub\n ? `${emailsHub}/templates/${templateScreenId}`\n : undefined,\n disabled: !templateScreenId || !emailsHub,\n disabledReason: templateScreenId\n ? 'This site’s console URL has not resolved yet'\n : 'This message was not built from a template',\n },\n ]\n }\n\n /*\n * The campaign's emails, on the surface's own row grammar: the row opens\n * the email's report, its subject is also a real link so it can be\n * middle-clicked and copied, and the trailing cluster holds the actions.\n * The window is one the card already holds, so the grid pages it.\n */\n const sendColumns: GridColDef<CampaignSend>[] = [\n {\n field: 'subject',\n headerName: 'Subject',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, send) => send.subject || send.$id,\n /*\n Plain text until the Emails hub resolves: a link with no destination\n is worse than none, and this cell is the message's subject either way.\n */\n renderCell: ({ row: send, value }) => {\n const href = sendHref(send)\n return href ? (\n <AppLink\n href={href}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ) : (\n value\n )\n },\n },\n {\n /*\n WHAT THE EMAIL IS DOING, not the field it stores.\n\n This branched on the status, which reads \"Scheduled\" about an email\n that has delivered five hundred messages and is between batches — the\n stored state the processor claims to resume it. The derivation reads\n the counters beside the status; the due date is appended only where\n there is genuinely nothing delivered yet, which is the one case a time\n answers.\n */\n field: 'state',\n headerName: 'State',\n width: 220,\n valueGetter: (_value, send) => {\n const display = campaignSendDisplay(send)\n return display.state === 'pending' && send.sendAtMs\n ? `${display.label} · ${new Date(send.sendAtMs).toLocaleString()}`\n : display.label\n },\n renderCell: ({ row: send, value }) => {\n const state = campaignSendDisplay(send).state\n return (\n <Chip\n size=\"small\"\n color={\n state === 'sending' ? 'info' : state === 'stopped' ? 'warning' : undefined\n }\n label={value}\n />\n )\n },\n },\n {\n /*\n Sent over addressed, on one line: the two figures only mean anything\n beside each other, and the gap between them is the suppression list\n doing its work.\n */\n field: 'sent',\n headerName: 'Sent',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 110,\n valueGetter: (_value, send) => Number(send.stats?.sent ?? 0),\n renderCell: ({ row: send }) =>\n `${send.stats?.sent ?? 0}/${send.stats?.recipients ?? 0}`,\n },\n {\n field: 'opens',\n headerName: 'Opens',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.opens ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n {\n field: 'clicks',\n headerName: 'Clicks',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.clicks ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n listActionsColumn(\n (send: CampaignSend) => (\n <ListRowActions\n label={String(send.subject || send.$id)}\n items={sendActions(send)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n const windowState = campaignWindowState(campaign, Date.now())\n const start = campaign.startAtMs\n ? new Date(campaign.startAtMs).toLocaleDateString()\n : ''\n const end = campaign.endAtMs\n ? new Date(campaign.endAtMs).toLocaleDateString()\n : ''\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign'}\n subheader={\n start || end\n ? `${start || 'Open'} – ${end || 'open-ended'}`\n : 'No campaign dates'\n }\n help={detailDocsHelp}\n HeaderProps={{\n /*\n NAVIGATION, THEN THE OVERFLOW.\n\n Editing and deleting a campaign live here rather than on the\n campaigns table, because a record is edited on its own page in this\n console. `RowActionsMenu` is named for table rows and its rendering\n is not — a kebab and a menu whose items carry `destructive` and\n `disabled` — so reusing it is what keeps this overflow behaving like\n every other one on the surface, including the email page's.\n */\n action: (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n <RowActionsMenu\n label={campaign.name || 'Campaign'}\n items={[\n {\n key: 'edit',\n label: 'Edit campaign',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n onClick: () => {\n setSaveError(null)\n setEditing(true)\n },\n },\n {\n key: 'delete',\n label: 'Delete campaign',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: deleting,\n disabledReason: 'This campaign is being deleted',\n onClick: () => void handleDelete(),\n },\n ]}\n />\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip size=\"small\" label={windowState} />\n {listIds.length ? (\n listIds.map((id) => (\n <Chip\n key={id}\n size=\"small\"\n variant=\"outlined\"\n label={names.get(id) ?? id}\n />\n ))\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No lists assigned — its emails pick their own audience'}\n </Typography>\n )}\n </Stack>\n\n <Divider />\n {/*\n A CAMPAIGN IS NOT ONLY ITS MAIL, and it leads with the part that\n is not.\n\n What the campaign CAUSED and where it SENT people are facts about the\n campaign; delivery, engagement and rates are mechanics of the\n messages it used, and the list of those messages is one section among\n several rather than the page. Ordering the mail first said the\n opposite in the only way a page can — by what a reader meets before\n they scroll — and a campaign of ONE email says it loudest, which is\n why `campaign-report-card.tsx` carries these two headings in this\n order too.\n\n All three sections join on the campaign's own send ids, which is the\n only handle the attribution, revenue and click-report collections\n offer — see `campaign-reach-sections.tsx` for why no other join\n exists, and why the web channel is named there rather than quietly\n omitted.\n */}\n <CampaignConversionsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n basePath={basePath}\n />\n\n <Divider />\n {/*\n WHAT IT EARNED, between what it caused and where it sent people —\n the order `campaign-report-card.tsx` puts these three in, so a\n campaign of one email and a campaign of ten answer the merchant's\n questions in the same sequence.\n\n How many messages a container holds is not a fact about whether its\n revenue is knowable, so the money question is answered here whatever\n the count. Reading it costs one record per email, so it asks first —\n see `campaign-reach-sections.tsx` for that bargain, and for why the\n currencies are reported apart and never totalled.\n */}\n <CampaignRevenueSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n <CampaignDestinationsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n {/*\n WHAT THE MERCHANT SAID BELONGS HERE, under what the visitors did.\n\n Everything above this line is measured — the conversions, the money\n and the pages the mail's own links pointed at, all joined on this\n campaign's send ids. This section is the only one on the page that\n reports a DECLARATION: a screen or a form carrying this campaign's\n id in its own document because somebody put it there.\n\n It sits after the evidence deliberately. A reader who met the\n assigned list first would take it for the campaign's reach, and the\n two are not the same list — a landing page can be assigned to a\n campaign no email ever linked to, and a campaign's mail can drive\n traffic to a page assigned to nothing.\n\n The dates go with it. A form's counters are lifetime, so a campaign\n with a window has to be able to confine them to its own months; a\n campaign without one is told so, and says its figures are lifetime\n rather than implying they are its own.\n */}\n <CampaignMembersSection\n hostId={hostId}\n campaignId={campaignId}\n startAtMs={campaign.startAtMs}\n endAtMs={campaign.endAtMs}\n />\n\n <Divider />\n {/*\n THE MAIL'S OWN FIGURES, under the outcomes rather than over them.\n\n The sum over the campaign's emails, not a second set of counters.\n Nothing is stored per campaign: a rollup document would have to be\n kept true against every delivery event of every email in it, and the\n numbers it duplicates are already on the sends this page reads.\n\n Drawn by the shared figures so a campaign's rate and a single\n message's read the same way — denominator named on the line, and an\n em dash rather than a zero where nothing was recorded.\n */}\n <Section title=\"The mail, across this campaign\">\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure label=\"Addressed\" {...rolled(rollup.addressed)} />\n <Figure label=\"Sent\" {...rolled(rollup.sent)} />\n <Figure label=\"Delivered\" {...rolled(rollup.delivered)} />\n <Figure label=\"Opens\" {...rolled(rollup.opens)} />\n <Figure label=\"Clicks\" {...rolled(rollup.clicks)} />\n <Figure label=\"Unsubscribed\" {...rolled(rollup.unsubscribes)} />\n </Stack>\n </Section>\n <Stack spacing={0.5}>\n <RateRow label=\"Open rate\" rate={rollup.openRate} />\n <RateRow label=\"Click rate\" rate={rollup.clickRate} />\n <RateRow label=\"Unsubscribe rate\" rate={rollup.unsubscribeRate} />\n </Stack>\n\n <Divider />\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', justifyContent: 'space-between' }}\n >\n {/*\n The section rail's own heading style, so this reads as one section\n among the others rather than as the page's subject.\n */}\n <Typography variant=\"overline\" color=\"text.secondary\">\n {`Emails (${sends.length})`}\n </Typography>\n <Button\n size=\"small\"\n variant={composing ? 'text' : 'contained'}\n onClick={() => setComposing((open) => !open)}\n >\n {composing ? 'Close composer' : 'Write an email'}\n </Button>\n </Stack>\n {sends.length ? (\n <Stack spacing={0.5}>\n <ListTable\n aria-label=\"The campaign's emails\"\n rows={sends}\n columns={sendColumns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, send) => {\n const href = sendHref(send)\n if (href) router.push(href)\n }}\n />\n {sendsTruncated ? (\n <Alert severity=\"info\">\n {`Showing ${CAMPAIGN_EMAIL_CEILING} of this campaign's ` +\n 'emails. It has sent more — the ones listed are not ' +\n 'necessarily the most recent, because a send carries no ' +\n 'date field that every writer stamps, and the figures above ' +\n 'cover the emails listed.'}\n </Alert>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails in this campaign yet.'}\n </Typography>\n )}\n\n {/*\n THE COMPOSER, ON DEMAND.\n\n It opens listens of its own — the site's email designs, the org's\n lists and segments, the running experiments — and this page is also\n where somebody arrives to read numbers. Mounting it unasked would\n make every reader pay for a composer they did not open, which is the\n same cost the campaign report was split onto its own route to avoid.\n */}\n {composing ? (\n <Box>\n <Divider sx={{ mb: 2 }} />\n <CampaignComposer\n hostId={hostId}\n emailCampaignId={campaignId}\n campaignListIds={listIds}\n campaignTopicId={campaign.topicId}\n onSent={() => setComposing(false)}\n />\n </Box>\n ) : null}\n </Stack>\n {/*\n THE EDIT DRAWER, on the campaign's own page.\n\n Its list picker reads the same `listDocs` the chips above already\n cost, so opening it adds one read — the topic catalog — and only\n while it is open.\n */}\n {/* Draws nothing: the zone the topic catalog's owner answers through. */}\n {topicSource}\n <CampaignEditDrawer\n open={editing}\n onClose={() => setEditing(false)}\n campaign={campaign}\n lists={(listDocs ?? []).map((list: any) => ({\n value: String(list.$id),\n label: String(list.name ?? list.$id),\n }))}\n topics={topicOptions}\n busy={saving}\n error={saveError}\n onSubmit={(values) => void handleSave(values)}\n />\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the campaign; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={campaign.name || campaignId} />\n {card}\n </>\n )\n}\nCampaignDetailCard.displayName = 'CampaignDetailCard'\n\nexport default CampaignDetailCard\n"],"names":["mdiDeleteOutline","mdiEyeOutline","mdiPaletteOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","Figure","RateRow","Section","ListRowActions","ListTable","listActionsColumn","RowActionsMenu","TABLE_ROW_HEIGHT","ceilingedWindow","PageHeaderRecord","pluginDocsHelp","Alert","Box","Button","Chip","Divider","Stack","Typography","collection","deleteField","doc","documentId","limit","orderBy","query","updateDoc","where","useCallback","useMemo","useState","useRouter","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useOrgDataScope","campaignRollup","campaignSendDisplay","campaignWindowState","emailListTimeMs","CampaignComposer","CampaignEditDrawer","CampaignConversionsSection","CampaignDestinationsSection","CampaignRevenueSection","CampaignMembersSection","CampaignReportCard","useCampaignManageApi","useEmailsHubPath","useCampaignTopicOptions","CAMPAIGN_EMAIL_CEILING","detailDocsHelp","anchor","excerpt","rolled","value","note","recorded","sends","CampaignDetailCard","props","campaign","hostId","campaignId","basePath","firestore","emailsHub","router","scope","dataScope","confirm","enqueueSnackbar","manageApi","composing","setComposing","editing","setEditing","saving","setSaving","saveError","setSaveError","deleting","setDeleting","data","status","sendDocs","Boolean","idField","listDocs","topics","source","topicSource","enabled","topicOptions","map","topic","id","label","name","rows","readSends","truncated","sendsTruncated","sort","a","b","rollup","sendIds","send","String","$id","filter","handleSave","values","startAtMs","endAtMs","listIds","topicId","variant","persist","error","console","handleDelete","unfinished","scheduled","sending","agreed","title","description","length","toLocaleString","confirmationText","then","catch","response","payload","action","ok","allowDuplicate","push","names","Map","list","sendHref","undefined","sendActions","templateScreenId","key","icon","path","size","href","disabled","disabledReason","sendColumns","field","headerName","flex","minWidth","valueGetter","_value","subject","renderCell","row","onClick","event","stopPropagation","width","display","state","sendAtMs","Date","color","type","align","headerAlign","Number","stats","sent","recipients","opens","valueFormatter","clicks","items","windowState","now","start","toLocaleDateString","end","card","header","subheader","help","HeaderProps","direction","spacing","sx","alignItems","component","componentVariant","nativeButton","destructive","contentGutterX","contentGutterY","get","flexWrap","addressed","delivered","unsubscribes","rate","openRate","clickRate","unsubscribeRate","justifyContent","open","aria-label","columns","rowHeight","onOpen","_id","severity","mb","emailCampaignId","campaignListIds","campaignTopicId","onSent","onClose","lists","busy","onSubmit","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-detail-card.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 {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPaletteOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n documentId,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignRollup,\n campaignSendDisplay,\n campaignWindowState,\n emailListTimeMs,\n type CampaignAggregate,\n type CampaignSend,\n type EmailCampaign,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport CampaignComposer from './campaign-composer'\nimport CampaignEditDrawer, {\n type CampaignEditValues,\n} from './campaign-edit-drawer'\nimport {\n CampaignConversionsSection,\n CampaignDestinationsSection,\n CampaignRevenueSection,\n CampaignSequencesSection,\n} from './campaign-reach-sections'\nimport CampaignMembersSection from './campaign-members-section'\nimport CampaignReportCard from './campaign-report-card'\nimport { useCampaignManageApi } from './use-campaign-send-api'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport { useCampaignTopicOptions } from './use-campaign-topic-options'\n\n/** How many of a campaign's emails the detail page enumerates. */\nconst CAMPAIGN_EMAIL_CEILING = 50\n\nconst detailDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A campaign groups the emails sent to its lists. The figures here are ' +\n 'the sum across those emails, and each one keeps its own report.',\n})\n\n/**\n * A rolled-up figure, in the shared `Figure`'s shape.\n *\n * The note is where an aggregate differs from a single send's count: a total\n * measured over fewer emails than the campaign holds says which part of the\n * campaign it describes, rather than presenting a partial sum as a complete\n * one. `null` still renders as an em dash and says \"not recorded\", which is\n * the property the shared component already owns.\n */\nconst rolled = (\n value: CampaignAggregate,\n): { value: number | null; note: string } => ({\n value: value.value,\n note:\n value.value !== null && value.recorded < value.sends\n ? `across ${value.recorded} of ${value.sends} emails`\n : `across ${value.sends} email${value.sends === 1 ? '' : 's'}`,\n})\n\nexport interface CampaignDetailCardProps {\n hostId: string\n /** A campaign container id, or a send id from before containers existed. */\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN: its lists, what it caused, what it earned, where it sent\n * people, and the emails it did all of that with.\n *\n * ## Why the emails are a section rather than the page\n *\n * A campaign reaches people by email, which is not the same as a campaign\n * BEING a list of emails. It runs over a window, against a set of audiences,\n * and the pages its links land on, the money credited to it and the\n * conversions credited to it are facts about the campaign that no single\n * message in it holds. So the body is sectioned — what it caused, what it\n * earned, where it sent people, then the mail's own figures, then its emails\n * — and each section names the population it describes.\n *\n * The ORDER is the part that says which of those the page is about. Delivery,\n * engagement and rates are mechanics of a message; a page that met the reader\n * with them and reached the outcomes only after scrolling was a mail report\n * whatever its headings claimed. `campaign-report-card.tsx` — the page a\n * campaign of one email gets — carries the same two outcome headings first,\n * for the same reason and in the same words.\n *\n * The three middle sections live in `campaign-reach-sections.tsx`, which is\n * also where the reason a campaign cannot simply DECLARE the screens and\n * forms it runs across is written down: no such edge is stored, attribution\n * is the mechanism, and all three sections join on this campaign's send ids.\n *\n * ## Why this resolves two kinds of id\n *\n * The id in `campaigns/{id}` may name a campaign CONTAINER or a single SEND,\n * and which it is is answered by reading: a container at `emailCampaigns/{id}`\n * renders this page, and anything else falls through to that send's own\n * report.\n *\n * Not every send belongs to a container. One written before campaigns grouped\n * their emails names none, its own id is the only id the URL can carry, and\n * that id is also inside the HMAC of every unsubscribe footer already\n * delivered — so a send id has to go on addressing a page rather than a 404.\n * The fall-through is what makes the container additive: no send document is\n * rewritten and no id is reassigned.\n *\n * The extra read is one document, and it buys the guarantee that a pasted\n * report link resolves whichever kind of id it carries.\n */\nexport function CampaignDetailCard(props: CampaignDetailCardProps) {\n const { hostId, campaignId, basePath } = props\n const firestore = useFirestore()\n // The sibling hub, for the two records this page links to but does not own:\n // each message's report and the template it was built from.\n const emailsHub = useEmailsHubPath()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n const manageApi = useCampaignManageApi(hostId)\n const [composing, setComposing] = useState(false)\n const [editing, setEditing] = useState(false)\n const [saving, setSaving] = useState(false)\n const [saveError, setSaveError] = useState<string | null>(null)\n const [deleting, setDeleting] = useState(false)\n\n const { data: campaign, status } = useFirestoreDoc<EmailCampaign>(\n () => doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n [firestore, hostId, campaignId],\n )\n\n /*\n * ONLY WHEN THE ID NAMES A CAMPAIGN.\n *\n * Both queries below are gated on the container having been found, because\n * the other half of this component is the single-send report — and that\n * screen's whole design is that it reads two documents whatever the size of\n * the audience. Listening unconditionally would add two collection reads to\n * every legacy report URL, which is the cost the report route was split out\n * to avoid. Not listening is what a null builder means.\n *\n * The campaign's emails come back by the field each send carries: equality\n * on one field ordered by DOCUMENT NAME, which Firestore's automatic\n * single-field index serves without a composite one. Ordering on a date\n * would need that index AND would drop every send missing that particular\n * date — a sent send carries `sentAt`, a scheduled one `sendAtMs`, and\n * neither is on both. Sorted by date below, over a window this page holds\n * whole.\n *\n * One document past the ceiling, so \"this campaign has more emails than are\n * listed\" is a fact rather than a guess.\n */\n const { data: sendDocs } = useFirestoreCollection<any>(\n () =>\n campaign\n ? query(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n where('emailCampaignId', '==', campaignId),\n orderBy(documentId()),\n limit(CAMPAIGN_EMAIL_CEILING + 1),\n )\n : null,\n [firestore, hostId, campaignId, Boolean(campaign)],\n { idField: '$id' },\n )\n\n const { data: listDocs } = useFirestoreCollection<any>(\n () =>\n campaign && dataScope\n ? query(\n collection(firestore, dataScope[0], dataScope[1], 'lists'),\n limit(50),\n )\n : null,\n [firestore, dataScope, Boolean(campaign)],\n { idField: '$id' },\n )\n\n /*\n * The org's topic catalog, read only while the EDIT DRAWER is open.\n *\n * It fills one picker in that drawer and is drawn nowhere else on this\n * page, so reading it on mount would charge every reader who came for the\n * campaign's numbers for a field they are not looking at. The composer\n * carries its own read of the same catalog, behind its own button, for the\n * same reason.\n */\n const { topics, source: topicSource } = useCampaignTopicOptions(hostId, {\n enabled: editing,\n })\n const topicOptions = useMemo(\n () =>\n topics.map((topic) => ({\n value: topic.id,\n label: topic.name,\n })),\n [topics],\n )\n\n const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow<any>(\n sendDocs,\n CAMPAIGN_EMAIL_CEILING,\n )\n /*\n * Newest first on the time each email SITS at — its send time where it has\n * one, its creation where it does not. A draft has neither `sentAt` nor\n * `sendAtMs`, so ordering on the send time alone gave every draft the key 0\n * and filed the email a merchant is in the middle of writing below mail\n * sent years ago.\n */\n const sends = useMemo(\n () =>\n [...(readSends as CampaignSend[])].sort(\n (a, b) => emailListTimeMs(b) - emailListTimeMs(a),\n ),\n [readSends],\n )\n const rollup = useMemo(() => campaignRollup(sends), [sends])\n // The ids the two sections beneath the figures join on. Derived from the\n // window this card already holds, so neither of them reads the send list\n // again to find out which emails the campaign has.\n const sendIds = useMemo(\n () => sends.map((send) => String(send.$id)).filter(Boolean),\n [sends],\n )\n\n /*==========================================\n * SAVING THE CONTAINER, WITH THE CLIENT SDK.\n *\n * The same door the create drawer already uses. `emailCampaigns` is\n * deliberately outside the security rules' server-only exclusion list — a\n * container holds no counter, no consent record and no entitlement input,\n * so an editor who writes one cannot buy themselves a send — and giving the\n * edit a route of its own would be a second writer to keep in step with the\n * create.\n *\n * `updateDoc` rather than a merge-set, because this is an edit of a\n * document that exists: a set would CREATE a campaign at this id for\n * somebody who deleted it in another tab, which is the shape the delivery\n * webhook is careful about on the send collection for the same reason.\n *\n * A cleared date is written as `null` — the absence the model already\n * spells — and a cleared topic REMOVES the field, because the model has no\n * null topic and `campaignTopicId` is handed to the composer as one.\n *=========================================*/\n const handleSave = useCallback(\n async (values: CampaignEditValues) => {\n if (saving) return\n setSaving(true)\n setSaveError(null)\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n {\n name: values.name,\n startAtMs: values.startAtMs,\n endAtMs: values.endAtMs,\n listIds: values.listIds,\n topicId: values.topicId ? values.topicId : deleteField(),\n },\n )\n setEditing(false)\n enqueueSnackbar('Campaign updated', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n setSaveError('The campaign could not be saved')\n } finally {\n setSaving(false)\n }\n },\n [campaignId, enqueueSnackbar, firestore, hostId, saving],\n )\n\n /*==========================================\n * DELETING THE CONTAINER, WHICH IS NOT DELETING ITS MAIL.\n *\n * The route detaches every email first and then removes the campaign, so\n * what a merchant is agreeing to is the grouping going away — the emails\n * stay, keep their ids, keep their reports, and read afterwards as single\n * sends. Their unsubscribe links carry those ids inside an HMAC and go on\n * working, which is why the container can never take them with it.\n *\n * The confirmation says the count and says what a delete does NOT do. A\n * scheduled email inside the campaign still goes out at its time — stopping\n * somebody's mail is `cancel`, on that email's own page — and a merchant\n * who reads \"delete campaign\" as \"stop the campaign\" has to be told\n * otherwise before they press it, not after.\n *=========================================*/\n const handleDelete = useCallback(async () => {\n if (deleting) return\n /*\n * Emails with mail still to go: the ones waiting for their time AND the\n * ones part way through an audience larger than one batch. Both are what\n * a merchant needs warning about, and counting only `scheduled` would\n * miss the case that needs it most — a campaign that is delivering right\n * now is stored as `scheduled` too, but `rollup.scheduled` is the\n * narrower reading that excludes it.\n */\n const unfinished = rollup.scheduled + rollup.sending\n const agreed = await confirm({\n title: 'Delete this campaign?',\n description:\n (sends.length\n ? `The ${sends.length.toLocaleString()} ` +\n `${sends.length === 1 ? 'email' : 'emails'} in it are kept — ` +\n 'each keeps its own report and its unsubscribe links, and they ' +\n 'appear in the campaigns list as single sends. '\n : 'It holds no emails. ') +\n (unfinished\n ? `${unfinished.toLocaleString()} of them ${\n unfinished === 1 ? 'is' : 'are'\n } still going out or still due, and deleting the campaign does ` +\n 'not stop that — the sender picks each one up from its own ' +\n 'record. Stop a send from its own page. '\n : '') +\n 'Any screens, forms and contacts assigned to it come out of it and ' +\n 'are otherwise untouched. Only the campaign itself goes.',\n confirmationText: 'Delete campaign',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDeleting(true)\n try {\n const { response, payload } = await manageApi({\n action: 'deleteCampaign',\n campaignId,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The campaign could not be deleted',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Campaign deleted', { variant: 'success', persist: false })\n router.push(`${basePath}/campaigns`)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The campaign could not be deleted', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDeleting(false)\n }\n }, [\n basePath,\n campaignId,\n confirm,\n deleting,\n enqueueSnackbar,\n manageApi,\n rollup.scheduled,\n rollup.sending,\n router,\n sends.length,\n ])\n\n // Still settling. Falling through to the send report here would flash \"this\n // campaign could not be loaded\" on every open of a campaign that exists.\n if (!campaign && status === 'loading') return null\n\n /*\n * NOT A CONTAINER — so it is a send id, and the send's own report is what\n * this URL has always meant.\n */\n if (!campaign) {\n return (\n <CampaignReportCard\n hostId={hostId}\n campaignId={campaignId}\n basePath={basePath}\n />\n )\n }\n\n const listIds = campaign.listIds ?? []\n const names = new Map<string, string>(\n (listDocs ?? []).map((list: any) => [\n String(list.$id),\n String(list.name ?? list.$id),\n ]),\n )\n /*==========================================\n * ONE EMAIL'S PAGE — the same one every other route to that record opens.\n *\n * The Emails hub's `emails/{sendId}`, which is where the messages list and\n * a template's messages table also send a reader. One record has one page:\n * a link that resolved somewhere else depending on which list it was\n * clicked in would give the same message two pages, and the reader would\n * have no way to tell which one they were on.\n *\n * `campaigns/{sendId}` on THIS hub goes on resolving as well, through the\n * fall-through at the top of this file. That is not a convenience — every\n * unsubscribe footer already delivered carries `cid={sendId}`, those\n * messages sit in inboxes forever, and merchants paste report URLs into\n * their own mail. Which link the console GENERATES and which URLs ANSWER\n * are two separate questions, and only the first is decided here.\n *\n * Nor is a redirect the answer to the second. A redirect would be another\n * thing to be wrong about an id that is inside an HMAC; a page that keeps\n * working has nothing to get wrong, and it costs nothing to leave standing.\n *=========================================*/\n const sendHref = (send: CampaignSend) =>\n emailsHub ? `${emailsHub}/messages/${send.$id}` : undefined\n\n /**\n * What one of this campaign's emails can be opened into.\n *\n * The same two the Emails hub offers, less the campaign — this page IS the\n * campaign. Both destinations are records the Emails console owns, so they\n * are built from {@link useEmailsHubPath} rather than from this surface's\n * own `basePath`.\n *\n * A message composed inline was built from no template, so that entry is\n * shown DISABLED with the reason rather than hidden: an absent control and\n * an inapplicable one look identical, and only one is honest.\n */\n const sendActions = (send: CampaignSend): RowActionsMenuItem[] => {\n const templateScreenId = String((send as any).templateScreenId ?? '')\n return [\n {\n key: 'details',\n label: 'Open report',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: sendHref(send),\n disabled: !sendHref(send),\n disabledReason: 'This site’s console URL has not resolved yet',\n },\n {\n key: 'template',\n label: 'Open its template',\n icon: <MdiIcon path={mdiPaletteOutline.path} size={0.8} />,\n href:\n templateScreenId && emailsHub\n ? `${emailsHub}/templates/${templateScreenId}`\n : undefined,\n disabled: !templateScreenId || !emailsHub,\n disabledReason: templateScreenId\n ? 'This site’s console URL has not resolved yet'\n : 'This message was not built from a template',\n },\n ]\n }\n\n /*\n * The campaign's emails, on the surface's own row grammar: the row opens\n * the email's report, its subject is also a real link so it can be\n * middle-clicked and copied, and the trailing cluster holds the actions.\n * The window is one the card already holds, so the grid pages it.\n */\n const sendColumns: GridColDef<CampaignSend>[] = [\n {\n field: 'subject',\n headerName: 'Subject',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, send) => send.subject || send.$id,\n /*\n Plain text until the Emails hub resolves: a link with no destination\n is worse than none, and this cell is the message's subject either way.\n */\n renderCell: ({ row: send, value }) => {\n const href = sendHref(send)\n return href ? (\n <AppLink\n href={href}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ) : (\n value\n )\n },\n },\n {\n /*\n WHAT THE EMAIL IS DOING, not the field it stores.\n\n This branched on the status, which reads \"Scheduled\" about an email\n that has delivered five hundred messages and is between batches — the\n stored state the processor claims to resume it. The derivation reads\n the counters beside the status; the due date is appended only where\n there is genuinely nothing delivered yet, which is the one case a time\n answers.\n */\n field: 'state',\n headerName: 'State',\n width: 220,\n valueGetter: (_value, send) => {\n const display = campaignSendDisplay(send)\n return display.state === 'pending' && send.sendAtMs\n ? `${display.label} · ${new Date(send.sendAtMs).toLocaleString()}`\n : display.label\n },\n renderCell: ({ row: send, value }) => {\n const state = campaignSendDisplay(send).state\n return (\n <Chip\n size=\"small\"\n color={\n state === 'sending' ? 'info' : state === 'stopped' ? 'warning' : undefined\n }\n label={value}\n />\n )\n },\n },\n {\n /*\n Sent over addressed, on one line: the two figures only mean anything\n beside each other, and the gap between them is the suppression list\n doing its work.\n */\n field: 'sent',\n headerName: 'Sent',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 110,\n valueGetter: (_value, send) => Number(send.stats?.sent ?? 0),\n renderCell: ({ row: send }) =>\n `${send.stats?.sent ?? 0}/${send.stats?.recipients ?? 0}`,\n },\n {\n field: 'opens',\n headerName: 'Opens',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.opens ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n {\n field: 'clicks',\n headerName: 'Clicks',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.clicks ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n listActionsColumn(\n (send: CampaignSend) => (\n <ListRowActions\n label={String(send.subject || send.$id)}\n items={sendActions(send)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n const windowState = campaignWindowState(campaign, Date.now())\n const start = campaign.startAtMs\n ? new Date(campaign.startAtMs).toLocaleDateString()\n : ''\n const end = campaign.endAtMs\n ? new Date(campaign.endAtMs).toLocaleDateString()\n : ''\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign'}\n subheader={\n start || end\n ? `${start || 'Open'} – ${end || 'open-ended'}`\n : 'No campaign dates'\n }\n help={detailDocsHelp}\n HeaderProps={{\n /*\n NAVIGATION, THEN THE OVERFLOW.\n\n Editing and deleting a campaign live here rather than on the\n campaigns table, because a record is edited on its own page in this\n console. `RowActionsMenu` is named for table rows and its rendering\n is not — a kebab and a menu whose items carry `destructive` and\n `disabled` — so reusing it is what keeps this overflow behaving like\n every other one on the surface, including the email page's.\n */\n action: (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n <RowActionsMenu\n label={campaign.name || 'Campaign'}\n items={[\n {\n key: 'edit',\n label: 'Edit campaign',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n onClick: () => {\n setSaveError(null)\n setEditing(true)\n },\n },\n {\n key: 'delete',\n label: 'Delete campaign',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: deleting,\n disabledReason: 'This campaign is being deleted',\n onClick: () => void handleDelete(),\n },\n ]}\n />\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip size=\"small\" label={windowState} />\n {listIds.length ? (\n listIds.map((id) => (\n <Chip\n key={id}\n size=\"small\"\n variant=\"outlined\"\n label={names.get(id) ?? id}\n />\n ))\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No lists assigned — its emails pick their own audience'}\n </Typography>\n )}\n </Stack>\n\n <Divider />\n {/*\n A CAMPAIGN IS NOT ONLY ITS MAIL, and it leads with the part that\n is not.\n\n What the campaign CAUSED and where it SENT people are facts about the\n campaign; delivery, engagement and rates are mechanics of the\n messages it used, and the list of those messages is one section among\n several rather than the page. Ordering the mail first said the\n opposite in the only way a page can — by what a reader meets before\n they scroll — and a campaign of ONE email says it loudest, which is\n why `campaign-report-card.tsx` carries these two headings in this\n order too.\n\n All three sections join on the campaign's own send ids, which is the\n only handle the attribution, revenue and click-report collections\n offer — see `campaign-reach-sections.tsx` for why no other join\n exists, and why the web channel is named there rather than quietly\n omitted.\n */}\n <CampaignConversionsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n basePath={basePath}\n />\n\n <Divider />\n {/*\n WHAT ITS SEQUENCES PRODUCED (AGL-3254), beside what its mail caused:\n a sequence in this campaign is a rep's one-to-one outreach, and its\n enrollments, sends, replies, meetings and conversions are the\n campaign's numbers too. Joined on the container's own id, since a\n sequence's emails are not the campaign's sends.\n */}\n <CampaignSequencesSection hostId={hostId} campaignId={campaignId} />\n\n <Divider />\n {/*\n WHAT IT EARNED, between what it caused and where it sent people —\n the order `campaign-report-card.tsx` puts these three in, so a\n campaign of one email and a campaign of ten answer the merchant's\n questions in the same sequence.\n\n How many messages a container holds is not a fact about whether its\n revenue is knowable, so the money question is answered here whatever\n the count. Reading it costs one record per email, so it asks first —\n see `campaign-reach-sections.tsx` for that bargain, and for why the\n currencies are reported apart and never totalled.\n */}\n <CampaignRevenueSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n <CampaignDestinationsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n {/*\n WHAT THE MERCHANT SAID BELONGS HERE, under what the visitors did.\n\n Everything above this line is measured — the conversions, the money\n and the pages the mail's own links pointed at, all joined on this\n campaign's send ids. This section is the only one on the page that\n reports a DECLARATION: a screen or a form carrying this campaign's\n id in its own document because somebody put it there.\n\n It sits after the evidence deliberately. A reader who met the\n assigned list first would take it for the campaign's reach, and the\n two are not the same list — a landing page can be assigned to a\n campaign no email ever linked to, and a campaign's mail can drive\n traffic to a page assigned to nothing.\n\n The dates go with it. A form's counters are lifetime, so a campaign\n with a window has to be able to confine them to its own months; a\n campaign without one is told so, and says its figures are lifetime\n rather than implying they are its own.\n */}\n <CampaignMembersSection\n hostId={hostId}\n campaignId={campaignId}\n startAtMs={campaign.startAtMs}\n endAtMs={campaign.endAtMs}\n />\n\n <Divider />\n {/*\n THE MAIL'S OWN FIGURES, under the outcomes rather than over them.\n\n The sum over the campaign's emails, not a second set of counters.\n Nothing is stored per campaign: a rollup document would have to be\n kept true against every delivery event of every email in it, and the\n numbers it duplicates are already on the sends this page reads.\n\n Drawn by the shared figures so a campaign's rate and a single\n message's read the same way — denominator named on the line, and an\n em dash rather than a zero where nothing was recorded.\n */}\n <Section title=\"The mail, across this campaign\">\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure label=\"Addressed\" {...rolled(rollup.addressed)} />\n <Figure label=\"Sent\" {...rolled(rollup.sent)} />\n <Figure label=\"Delivered\" {...rolled(rollup.delivered)} />\n <Figure label=\"Opens\" {...rolled(rollup.opens)} />\n <Figure label=\"Clicks\" {...rolled(rollup.clicks)} />\n <Figure label=\"Unsubscribed\" {...rolled(rollup.unsubscribes)} />\n </Stack>\n </Section>\n <Stack spacing={0.5}>\n <RateRow label=\"Open rate\" rate={rollup.openRate} />\n <RateRow label=\"Click rate\" rate={rollup.clickRate} />\n <RateRow label=\"Unsubscribe rate\" rate={rollup.unsubscribeRate} />\n </Stack>\n\n <Divider />\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', justifyContent: 'space-between' }}\n >\n {/*\n The section rail's own heading style, so this reads as one section\n among the others rather than as the page's subject.\n */}\n <Typography variant=\"overline\" color=\"text.secondary\">\n {`Emails (${sends.length})`}\n </Typography>\n <Button\n size=\"small\"\n variant={composing ? 'text' : 'contained'}\n onClick={() => setComposing((open) => !open)}\n >\n {composing ? 'Close composer' : 'Write an email'}\n </Button>\n </Stack>\n {sends.length ? (\n <Stack spacing={0.5}>\n <ListTable\n aria-label=\"The campaign's emails\"\n rows={sends}\n columns={sendColumns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, send) => {\n const href = sendHref(send)\n if (href) router.push(href)\n }}\n />\n {sendsTruncated ? (\n <Alert severity=\"info\">\n {`Showing ${CAMPAIGN_EMAIL_CEILING} of this campaign's ` +\n 'emails. It has sent more — the ones listed are not ' +\n 'necessarily the most recent, because a send carries no ' +\n 'date field that every writer stamps, and the figures above ' +\n 'cover the emails listed.'}\n </Alert>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails in this campaign yet.'}\n </Typography>\n )}\n\n {/*\n THE COMPOSER, ON DEMAND.\n\n It opens listens of its own — the site's email designs, the org's\n lists and segments, the running experiments — and this page is also\n where somebody arrives to read numbers. Mounting it unasked would\n make every reader pay for a composer they did not open, which is the\n same cost the campaign report was split onto its own route to avoid.\n */}\n {composing ? (\n <Box>\n <Divider sx={{ mb: 2 }} />\n <CampaignComposer\n hostId={hostId}\n emailCampaignId={campaignId}\n campaignListIds={listIds}\n campaignTopicId={campaign.topicId}\n onSent={() => setComposing(false)}\n />\n </Box>\n ) : null}\n </Stack>\n {/*\n THE EDIT DRAWER, on the campaign's own page.\n\n Its list picker reads the same `listDocs` the chips above already\n cost, so opening it adds one read — the topic catalog — and only\n while it is open.\n */}\n {/* Draws nothing: the zone the topic catalog's owner answers through. */}\n {topicSource}\n <CampaignEditDrawer\n open={editing}\n onClose={() => setEditing(false)}\n campaign={campaign}\n lists={(listDocs ?? []).map((list: any) => ({\n value: String(list.$id),\n label: String(list.name ?? list.$id),\n }))}\n topics={topicOptions}\n busy={saving}\n error={saveError}\n onSubmit={(values) => void handleSave(values)}\n />\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the campaign; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={campaign.name || campaignId} />\n {card}\n </>\n )\n}\nCampaignDetailCard.displayName = 'CampaignDetailCard'\n\nexport default CampaignDetailCard\n"],"names":["mdiDeleteOutline","mdiEyeOutline","mdiPaletteOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","Figure","RateRow","Section","ListRowActions","ListTable","listActionsColumn","RowActionsMenu","TABLE_ROW_HEIGHT","ceilingedWindow","PageHeaderRecord","pluginDocsHelp","Alert","Box","Button","Chip","Divider","Stack","Typography","collection","deleteField","doc","documentId","limit","orderBy","query","updateDoc","where","useCallback","useMemo","useState","useRouter","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useOrgDataScope","campaignRollup","campaignSendDisplay","campaignWindowState","emailListTimeMs","CampaignComposer","CampaignEditDrawer","CampaignConversionsSection","CampaignDestinationsSection","CampaignRevenueSection","CampaignSequencesSection","CampaignMembersSection","CampaignReportCard","useCampaignManageApi","useEmailsHubPath","useCampaignTopicOptions","CAMPAIGN_EMAIL_CEILING","detailDocsHelp","anchor","excerpt","rolled","value","note","recorded","sends","CampaignDetailCard","props","campaign","hostId","campaignId","basePath","firestore","emailsHub","router","scope","dataScope","confirm","enqueueSnackbar","manageApi","composing","setComposing","editing","setEditing","saving","setSaving","saveError","setSaveError","deleting","setDeleting","data","status","sendDocs","Boolean","idField","listDocs","topics","source","topicSource","enabled","topicOptions","map","topic","id","label","name","rows","readSends","truncated","sendsTruncated","sort","a","b","rollup","sendIds","send","String","$id","filter","handleSave","values","startAtMs","endAtMs","listIds","topicId","variant","persist","error","console","handleDelete","unfinished","scheduled","sending","agreed","title","description","length","toLocaleString","confirmationText","then","catch","response","payload","action","ok","allowDuplicate","push","names","Map","list","sendHref","undefined","sendActions","templateScreenId","key","icon","path","size","href","disabled","disabledReason","sendColumns","field","headerName","flex","minWidth","valueGetter","_value","subject","renderCell","row","onClick","event","stopPropagation","width","display","state","sendAtMs","Date","color","type","align","headerAlign","Number","stats","sent","recipients","opens","valueFormatter","clicks","items","windowState","now","start","toLocaleDateString","end","card","header","subheader","help","HeaderProps","direction","spacing","sx","alignItems","component","componentVariant","nativeButton","destructive","contentGutterX","contentGutterY","get","flexWrap","addressed","delivered","unsubscribes","rate","openRate","clickRate","unsubscribeRate","justifyContent","open","aria-label","columns","rowHeight","onOpen","_id","severity","mb","emailCampaignId","campaignListIds","campaignTopicId","onSent","onClose","lists","busy","onSubmit","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SACEA,gBAAgB,EAChBC,aAAa,EACbC,iBAAiB,EACjBC,gBAAgB,QACX,yBAAwB;AAC/B,SACEC,OAAO,EACPC,WAAW,EACXC,OAAO,EACPC,sBAAsB,QACjB,uBAAsB;AAC7B,SACEC,MAAM,EACNC,OAAO,EACPC,OAAO,QACF,6DAA4D;AACnE,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAC7D,OAAOC,oBAEA,6DAA4D;AACnE,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SAASC,eAAe,QAAQ,+DAA8D;AAC9F,SAASC,gBAAgB,EAAEC,cAAc,QAAQ,eAAc;AAC/D,SACEC,KAAK,EACLC,GAAG,EACHC,MAAM,EACNC,IAAI,EACJC,OAAO,EACPC,KAAK,EACLC,UAAU,QACL,gBAAe;AAEtB,SACEC,UAAU,EACVC,WAAW,EACXC,GAAG,EACHC,UAAU,EACVC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,SAAS,EACTC,KAAK,QACA,qBAAoB;AAC3B,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SACEC,YAAY,EACZC,sBAAsB,EACtBC,eAAe,EACfC,eAAe,QACV,iCAAgC;AACvC,SACEC,cAAc,EACdC,mBAAmB,EACnBC,mBAAmB,EACnBC,eAAe,QAIV,yCAAwC;AAC/C,OAAOC,sBAAsB,yBAAqB;AAClD,OAAOC,wBAEA,4BAAwB;AAC/B,SACEC,0BAA0B,EAC1BC,2BAA2B,EAC3BC,sBAAsB,EACtBC,wBAAwB,QACnB,+BAA2B;AAClC,OAAOC,4BAA4B,gCAA4B;AAC/D,OAAOC,wBAAwB,4BAAwB;AACvD,SAASC,oBAAoB,QAAQ,6BAAyB;AAC9D,SAASC,gBAAgB,QAAQ,2BAAuB;AACxD,SAASC,uBAAuB,QAAQ,kCAA8B;AAEtE,gEAAgE,GAChE,MAAMC,yBAAyB;AAE/B,MAAMC,iBAAiB1C,eAAe,kBAAkB;IACtD2C,QAAQ;IACRC,SACE,0EACA;AACJ;AAEA;;;;;;;;CAQC,GACD,MAAMC,SAAS,CACbC,QAC4C,CAAA;QAC5CA,OAAOA,MAAMA,KAAK;QAClBC,MACED,MAAMA,KAAK,KAAK,QAAQA,MAAME,QAAQ,GAAGF,MAAMG,KAAK,GAChD,CAAC,OAAO,EAAEH,MAAME,QAAQ,CAAC,IAAI,EAAEF,MAAMG,KAAK,CAAC,OAAO,CAAC,GACnD,CAAC,OAAO,EAAEH,MAAMG,KAAK,CAAC,MAAM,EAAEH,MAAMG,KAAK,KAAK,IAAI,KAAK,KAAK;IACpE,CAAA;AAUA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GACD,OAAO,SAASC,mBAAmBC,KAA8B;QA8Q/CC;IA7QhB,MAAM,EAAEC,MAAM,EAAEC,UAAU,EAAEC,QAAQ,EAAE,GAAGJ;IACzC,MAAMK,YAAYlC;IAClB,4EAA4E;IAC5E,4DAA4D;IAC5D,MAAMmC,YAAYlB;IAClB,MAAMmB,SAAStC;IACf,MAAM,EAAEuC,OAAOC,SAAS,EAAE,GAAGnC,gBAAgB;QAAE4B;IAAO;IACtD,MAAM,EAAEQ,OAAO,EAAE,GAAGxE;IACpB,MAAM,EAAEyE,eAAe,EAAE,GAAGzC;IAC5B,MAAM0C,YAAYzB,qBAAqBe;IACvC,MAAM,CAACW,WAAWC,aAAa,GAAG9C,SAAS;IAC3C,MAAM,CAAC+C,SAASC,WAAW,GAAGhD,SAAS;IACvC,MAAM,CAACiD,QAAQC,UAAU,GAAGlD,SAAS;IACrC,MAAM,CAACmD,WAAWC,aAAa,GAAGpD,SAAwB;IAC1D,MAAM,CAACqD,UAAUC,YAAY,GAAGtD,SAAS;IAEzC,MAAM,EAAEuD,MAAMtB,QAAQ,EAAEuB,MAAM,EAAE,GAAGnD,gBACjC,IAAMd,IAAI8C,WAAW,SAASH,QAAQ,kBAAkBC,aACxD;QAACE;QAAWH;QAAQC;KAAW;IAGjC;;;;;;;;;;;;;;;;;;;;GAoBC,GACD,MAAM,EAAEoB,MAAME,QAAQ,EAAE,GAAGrD,uBACzB,IACE6B,WACItC,MACEN,WAAWgD,WAAW,SAASH,QAAQ,cACvCrC,MAAM,mBAAmB,MAAMsC,aAC/BzC,QAAQF,eACRC,MAAM6B,yBAAyB,MAEjC,MACN;QAACe;QAAWH;QAAQC;QAAYuB,QAAQzB;KAAU,EAClD;QAAE0B,SAAS;IAAM;IAGnB,MAAM,EAAEJ,MAAMK,QAAQ,EAAE,GAAGxD,uBACzB,IACE6B,YAAYQ,YACR9C,MACEN,WAAWgD,WAAWI,SAAS,CAAC,EAAE,EAAEA,SAAS,CAAC,EAAE,EAAE,UAClDhD,MAAM,OAER,MACN;QAAC4C;QAAWI;QAAWiB,QAAQzB;KAAU,EACzC;QAAE0B,SAAS;IAAM;IAGnB;;;;;;;;GAQC,GACD,MAAM,EAAEE,MAAM,EAAEC,QAAQC,WAAW,EAAE,GAAG1C,wBAAwBa,QAAQ;QACtE8B,SAASjB;IACX;IACA,MAAMkB,eAAelE,QACnB,IACE8D,OAAOK,GAAG,CAAC,CAACC,QAAW,CAAA;gBACrBxC,OAAOwC,MAAMC,EAAE;gBACfC,OAAOF,MAAMG,IAAI;YACnB,CAAA,IACF;QAACT;KAAO;IAGV,MAAM,EAAEU,MAAMC,SAAS,EAAEC,WAAWC,cAAc,EAAE,GAAG/F,gBACrD8E,UACAnC;IAEF;;;;;;GAMC,GACD,MAAMQ,QAAQ/B,QACZ,IACE;eAAKyE;SAA6B,CAACG,IAAI,CACrC,CAACC,GAAGC,IAAMnE,gBAAgBmE,KAAKnE,gBAAgBkE,KAEnD;QAACJ;KAAU;IAEb,MAAMM,SAAS/E,QAAQ,IAAMQ,eAAeuB,QAAQ;QAACA;KAAM;IAC3D,yEAAyE;IACzE,yEAAyE;IACzE,mDAAmD;IACnD,MAAMiD,UAAUhF,QACd,IAAM+B,MAAMoC,GAAG,CAAC,CAACc,OAASC,OAAOD,KAAKE,GAAG,GAAGC,MAAM,CAACzB,UACnD;QAAC5B;KAAM;IAGT;;;;;;;;;;;;;;;;;;6CAkB2C,GAC3C,MAAMsD,aAAatF,YACjB,OAAOuF;QACL,IAAIpC,QAAQ;QACZC,UAAU;QACVE,aAAa;QACb,IAAI;YACF,MAAMxD,UACJL,IAAI8C,WAAW,SAASH,QAAQ,kBAAkBC,aAClD;gBACEmC,MAAMe,OAAOf,IAAI;gBACjBgB,WAAWD,OAAOC,SAAS;gBAC3BC,SAASF,OAAOE,OAAO;gBACvBC,SAASH,OAAOG,OAAO;gBACvBC,SAASJ,OAAOI,OAAO,GAAGJ,OAAOI,OAAO,GAAGnG;YAC7C;YAEF0D,WAAW;YACXL,gBAAgB,oBAAoB;gBAClC+C,SAAS;gBACTC,SAAS;YACX;QACF,EAAE,OAAOC,OAAO;YACdC,QAAQD,KAAK,CAACA;YACdxC,aAAa;QACf,SAAU;YACRF,UAAU;QACZ;IACF,GACA;QAACf;QAAYQ;QAAiBN;QAAWH;QAAQe;KAAO;IAG1D;;;;;;;;;;;;;;6CAc2C,GAC3C,MAAM6C,eAAehG,YAAY;QAC/B,IAAIuD,UAAU;QACd;;;;;;;KAOC,GACD,MAAM0C,aAAajB,OAAOkB,SAAS,GAAGlB,OAAOmB,OAAO;QACpD,MAAMC,SAAS,MAAMxD,QAAQ;YAC3ByD,OAAO;YACPC,aACE,AAACtE,CAAAA,MAAMuE,MAAM,GACT,CAAC,IAAI,EAAEvE,MAAMuE,MAAM,CAACC,cAAc,GAAG,CAAC,CAAC,GACvC,GAAGxE,MAAMuE,MAAM,KAAK,IAAI,UAAU,SAAS,kBAAkB,CAAC,GAC9D,mEACA,mDACA,sBAAqB,IACxBN,CAAAA,aACG,GAAGA,WAAWO,cAAc,GAAG,SAAS,EACtCP,eAAe,IAAI,OAAO,MAC3B,8DAA8D,CAAC,GAChE,+DACA,4CACA,EAAC,IACL,uEACA;YACFQ,kBAAkB;QACpB,GACGC,IAAI,CAAC,IAAM,MACXC,KAAK,CAAC,IAAM;QACf,IAAI,CAACP,QAAQ;QACb5C,YAAY;QACZ,IAAI;YACF,MAAM,EAAEoD,QAAQ,EAAEC,OAAO,EAAE,GAAG,MAAM/D,UAAU;gBAC5CgE,QAAQ;gBACRzE;YACF;YACA,IAAI,CAACuE,SAASG,EAAE,EAAE;;gBAChB,OAAO,KAAKlE,wBACVgE,2BAAAA,QAASf,KAAK,mBAAI,qCAClB;oBAAEF,SAAS;oBAAWoB,gBAAgB;gBAAK;YAE/C;YACAnE,gBAAgB,oBAAoB;gBAAE+C,SAAS;gBAAWC,SAAS;YAAM;YACzEpD,OAAOwE,IAAI,CAAC,GAAG3E,SAAS,UAAU,CAAC;QACrC,EAAE,OAAOwD,OAAO;YACdC,QAAQD,KAAK,CAACA;YACdjD,gBAAgB,qCAAqC;gBACnD+C,SAAS;gBACToB,gBAAgB;YAClB;QACF,SAAU;YACRxD,YAAY;QACd;IACF,GAAG;QACDlB;QACAD;QACAO;QACAW;QACAV;QACAC;QACAkC,OAAOkB,SAAS;QAChBlB,OAAOmB,OAAO;QACd1D;QACAT,MAAMuE,MAAM;KACb;IAED,4EAA4E;IAC5E,yEAAyE;IACzE,IAAI,CAACpE,YAAYuB,WAAW,WAAW,OAAO;IAE9C;;;GAGC,GACD,IAAI,CAACvB,UAAU;QACb,qBACE,KAACf;YACCgB,QAAQA;YACRC,YAAYA;YACZC,UAAUA;;IAGhB;IAEA,MAAMoD,WAAUvD,oBAAAA,SAASuD,OAAO,YAAhBvD,oBAAoB,EAAE;IACtC,MAAM+E,QAAQ,IAAIC,IAChB,CAACrD,mBAAAA,WAAY,EAAE,EAAEM,GAAG,CAAC,CAACgD;YAEbA;eAF2B;YAClCjC,OAAOiC,KAAKhC,GAAG;YACfD,QAAOiC,aAAAA,KAAK5C,IAAI,YAAT4C,aAAaA,KAAKhC,GAAG;SAC7B;;IAEH;;;;;;;;;;;;;;;;;;;6CAmB2C,GAC3C,MAAMiC,WAAW,CAACnC,OAChB1C,YAAY,GAAGA,UAAU,UAAU,EAAE0C,KAAKE,GAAG,EAAE,GAAGkC;IAEpD;;;;;;;;;;;GAWC,GACD,MAAMC,cAAc,CAACrC;YACa;QAAhC,MAAMsC,mBAAmBrC,QAAO,yBAAA,AAACD,KAAasC,gBAAgB,YAA9B,yBAAkC;QAClE,OAAO;YACL;gBACEC,KAAK;gBACLlD,OAAO;gBACPmD,oBAAM,KAACvJ;oBAAQwJ,MAAM7J,cAAc6J,IAAI;oBAAEC,MAAM;;gBAC/CC,MAAMR,SAASnC;gBACf4C,UAAU,CAACT,SAASnC;gBACpB6C,gBAAgB;YAClB;YACA;gBACEN,KAAK;gBACLlD,OAAO;gBACPmD,oBAAM,KAACvJ;oBAAQwJ,MAAM5J,kBAAkB4J,IAAI;oBAAEC,MAAM;;gBACnDC,MACEL,oBAAoBhF,YAChB,GAAGA,UAAU,WAAW,EAAEgF,kBAAkB,GAC5CF;gBACNQ,UAAU,CAACN,oBAAoB,CAAChF;gBAChCuF,gBAAgBP,mBACZ,iDACA;YACN;SACD;IACH;IAEA;;;;;GAKC,GACD,MAAMQ,cAA0C;QAC9C;YACEC,OAAO;YACPC,YAAY;YACZC,MAAM;YACNC,UAAU;YACVC,aAAa,CAACC,QAAQpD,OAASA,KAAKqD,OAAO,IAAIrD,KAAKE,GAAG;YACvD;;;OAGC,GACDoD,YAAY,CAAC,EAAEC,KAAKvD,IAAI,EAAErD,KAAK,EAAE;gBAC/B,MAAMgG,OAAOR,SAASnC;gBACtB,OAAO2C,qBACL,KAAC5J;oBACC4J,MAAMA;oBACN,+DAA+D;oBAC/D,4CAA4C;oBAC5Ca,SAAS,CAACC,QACRA,MAAMC,eAAe;8BAGtB/G;qBAGHA;YAEJ;QACF;QACA;YACE;;;;;;;;;OASC,GACDoG,OAAO;YACPC,YAAY;YACZW,OAAO;YACPR,aAAa,CAACC,QAAQpD;gBACpB,MAAM4D,UAAUpI,oBAAoBwE;gBACpC,OAAO4D,QAAQC,KAAK,KAAK,aAAa7D,KAAK8D,QAAQ,GAC/C,GAAGF,QAAQvE,KAAK,CAAC,GAAG,EAAE,IAAI0E,KAAK/D,KAAK8D,QAAQ,EAAExC,cAAc,IAAI,GAChEsC,QAAQvE,KAAK;YACnB;YACAiE,YAAY,CAAC,EAAEC,KAAKvD,IAAI,EAAErD,KAAK,EAAE;gBAC/B,MAAMkH,QAAQrI,oBAAoBwE,MAAM6D,KAAK;gBAC7C,qBACE,KAAC5J;oBACCyI,MAAK;oBACLsB,OACEH,UAAU,YAAY,SAASA,UAAU,YAAY,YAAYzB;oBAEnE/C,OAAO1C;;YAGb;QACF;QACA;YACE;;;;OAIC,GACDoG,OAAO;YACPC,YAAY;YACZiB,MAAM;YACNC,OAAO;YACPC,aAAa;YACbR,OAAO;YACPR,aAAa,CAACC,QAAQpD;;oBAAgBA;uBAAPoE,gBAAOpE,cAAAA,KAAKqE,KAAK,qBAAVrE,YAAYsE,IAAI,mBAAI;;YAC1DhB,YAAY,CAAC,EAAEC,KAAKvD,IAAI,EAAE;;oBACrBA,aAAyBA;uBAA5B,YAAGA,cAAAA,KAAKqE,KAAK,qBAAVrE,YAAYsE,IAAI,mBAAI,EAAE,CAAC,YAAEtE,eAAAA,KAAKqE,KAAK,qBAAVrE,aAAYuE,UAAU,oBAAI,GAAG;;QAC7D;QACA;YACExB,OAAO;YACPC,YAAY;YACZiB,MAAM;YACNC,OAAO;YACPC,aAAa;YACbR,OAAO;YACPR,aAAa,CAACC,QAAQpD;;oBAAgBA;uBAAPoE,gBAAOpE,cAAAA,KAAKqE,KAAK,qBAAVrE,YAAYwE,KAAK,mBAAI;;YAC3DC,gBAAgB,CAAC9H,QAAkBA,MAAM2E,cAAc;QACzD;QACA;YACEyB,OAAO;YACPC,YAAY;YACZiB,MAAM;YACNC,OAAO;YACPC,aAAa;YACbR,OAAO;YACPR,aAAa,CAACC,QAAQpD;;oBAAgBA;uBAAPoE,gBAAOpE,cAAAA,KAAKqE,KAAK,qBAAVrE,YAAY0E,MAAM,mBAAI;;YAC5DD,gBAAgB,CAAC9H,QAAkBA,MAAM2E,cAAc;QACzD;QACA9H,kBACE,CAACwG,qBACC,KAAC1G;gBACC+F,OAAOY,OAAOD,KAAKqD,OAAO,IAAIrD,KAAKE,GAAG;gBACtCyE,OAAOtC,YAAYrC;gBAGvB;YAAE2D,OAAO;QAAG;KAEf;IAED,MAAMiB,cAAcnJ,oBAAoBwB,UAAU8G,KAAKc,GAAG;IAC1D,MAAMC,QAAQ7H,SAASqD,SAAS,GAC5B,IAAIyD,KAAK9G,SAASqD,SAAS,EAAEyE,kBAAkB,KAC/C;IACJ,MAAMC,MAAM/H,SAASsD,OAAO,GACxB,IAAIwD,KAAK9G,SAASsD,OAAO,EAAEwE,kBAAkB,KAC7C;IAEJ;yCACuC,GACvC,MAAME,qBACJ,MAACjM;QACCkM,QAAQ;QACRC,WACEL,SAASE,MACL,GAAGF,SAAS,OAAO,GAAG,EAAEE,OAAO,cAAc,GAC7C;QAENI,MAAM7I;QACN8I,aAAa;YACX;;;;;;;;;SASC,GACDzD,sBACE,MAACzH;gBAAMmL,WAAU;gBAAMC,SAAS;gBAAGC,IAAI;oBAAEC,YAAY;gBAAS;;kCAC5D,KAACzL;wBACC0L,WAAW3M;uBACN;wBAAE4M,kBAAkB;wBAASC,cAAc;oBAAM;wBACtDjD,MAAM,GAAGvF,SAAS,UAAU,CAAC;wBAC7BsF,MAAK;wBACLsB,OAAM;kCAEL;;kCAEH,KAACvK;wBACC4F,OAAOpC,SAASqC,IAAI,IAAI;wBACxBqF,OAAO;4BACL;gCACEpC,KAAK;gCACLlD,OAAO;gCACPmD,oBAAM,KAACvJ;oCAAQwJ,MAAM3J,iBAAiB2J,IAAI;oCAAEC,MAAM;;gCAClDc,SAAS;oCACPpF,aAAa;oCACbJ,WAAW;gCACb;4BACF;4BACA;gCACEuE,KAAK;gCACLlD,OAAO;gCACPmD,oBAAM,KAACvJ;oCAAQwJ,MAAM9J,iBAAiB8J,IAAI;oCAAEC,MAAM;;gCAClDmD,aAAa;gCACbjD,UAAUvE;gCACVwE,gBAAgB;gCAChBW,SAAS,IAAM,KAAK1C;4BACtB;yBACD;;;;QAIT;QACAgF,cAAc;QACdC,cAAc;;0BAEd,MAAC5L;gBAAMoL,SAAS;;kCACd,MAACpL;wBAAMmL,WAAU;wBAAMC,SAAS;wBAAGC,IAAI;4BAAEC,YAAY;wBAAS;;0CAC5D,KAACxL;gCAAKyI,MAAK;gCAAQrD,OAAOuF;;4BACzBpE,QAAQa,MAAM,GACbb,QAAQtB,GAAG,CAAC,CAACE;oCAKF4C;qDAJT,KAAC/H;oCAECyI,MAAK;oCACLhC,SAAQ;oCACRrB,KAAK,GAAE2C,aAAAA,MAAMgE,GAAG,CAAC5G,eAAV4C,aAAiB5C;mCAHnBA;+CAOT,KAAChF;gCAAWsG,SAAQ;gCAAUsD,OAAM;0CACjC;;;;kCAKP,KAAC9J;kCAoBD,KAAC2B;wBACCqB,QAAQA;wBACR6C,SAASA;wBACTN,WAAWC;wBACXtC,UAAUA;;kCAGZ,KAAClD;kCAQD,KAAC8B;wBAAyBkB,QAAQA;wBAAQC,YAAYA;;kCAEtD,KAACjD;kCAaD,KAAC6B;wBACCmB,QAAQA;wBACR6C,SAASA;wBACTN,WAAWC;;kCAGb,KAACxF;kCACD,KAAC4B;wBACCoB,QAAQA;wBACR6C,SAASA;wBACTN,WAAWC;;kCAGb,KAACxF;kCAqBD,KAAC+B;wBACCiB,QAAQA;wBACRC,YAAYA;wBACZmD,WAAWrD,SAASqD,SAAS;wBAC7BC,SAAStD,SAASsD,OAAO;;kCAG3B,KAACrG;kCAaD,KAACb;wBAAQ8H,OAAM;kCACb,cAAA,MAAChH;4BAAMmL,WAAU;4BAAMC,SAAS;4BAAGC,IAAI;gCAAES,UAAU;4BAAO;;8CACxD,KAAC9M;oCAAOkG,OAAM;mCAAgB3C,OAAOoD,OAAOoG,SAAS;8CACrD,KAAC/M;oCAAOkG,OAAM;mCAAW3C,OAAOoD,OAAOwE,IAAI;8CAC3C,KAACnL;oCAAOkG,OAAM;mCAAgB3C,OAAOoD,OAAOqG,SAAS;8CACrD,KAAChN;oCAAOkG,OAAM;mCAAY3C,OAAOoD,OAAO0E,KAAK;8CAC7C,KAACrL;oCAAOkG,OAAM;mCAAa3C,OAAOoD,OAAO4E,MAAM;8CAC/C,KAACvL;oCAAOkG,OAAM;mCAAmB3C,OAAOoD,OAAOsG,YAAY;;;;kCAG/D,MAACjM;wBAAMoL,SAAS;;0CACd,KAACnM;gCAAQiG,OAAM;gCAAYgH,MAAMvG,OAAOwG,QAAQ;;0CAChD,KAAClN;gCAAQiG,OAAM;gCAAagH,MAAMvG,OAAOyG,SAAS;;0CAClD,KAACnN;gCAAQiG,OAAM;gCAAmBgH,MAAMvG,OAAO0G,eAAe;;;;kCAGhE,KAACtM;kCACD,MAACC;wBACCmL,WAAU;wBACVC,SAAS;wBACTC,IAAI;4BAAEC,YAAY;4BAAUgB,gBAAgB;wBAAgB;;0CAM5D,KAACrM;gCAAWsG,SAAQ;gCAAWsD,OAAM;0CAClC,CAAC,QAAQ,EAAElH,MAAMuE,MAAM,CAAC,CAAC,CAAC;;0CAE7B,KAACrH;gCACC0I,MAAK;gCACLhC,SAAS7C,YAAY,SAAS;gCAC9B2F,SAAS,IAAM1F,aAAa,CAAC4I,OAAS,CAACA;0CAEtC7I,YAAY,mBAAmB;;;;oBAGnCf,MAAMuE,MAAM,iBACX,MAAClH;wBAAMoL,SAAS;;0CACd,KAAChM;gCACCoN,cAAW;gCACXpH,MAAMzC;gCACN8J,SAAS9D;gCACT+D,WAAWnN;gCACXoN,QAAQ,CAACC,KAAK/G;oCACZ,MAAM2C,OAAOR,SAASnC;oCACtB,IAAI2C,MAAMpF,OAAOwE,IAAI,CAACY;gCACxB;;4BAEDjD,+BACC,KAAC5F;gCAAMkN,UAAS;0CACb,CAAC,QAAQ,EAAE1K,uBAAuB,oBAAoB,CAAC,GACtD,wDACA,4DACA,gEACA;iCAEF;;uCAGN,KAAClC;wBAAWsG,SAAQ;wBAAQsD,OAAM;kCAC/B;;oBAaJnG,0BACC,MAAC9D;;0CACC,KAACG;gCAAQsL,IAAI;oCAAEyB,IAAI;gCAAE;;0CACrB,KAACtL;gCACCuB,QAAQA;gCACRgK,iBAAiB/J;gCACjBgK,iBAAiB3G;gCACjB4G,iBAAiBnK,SAASwD,OAAO;gCACjC4G,QAAQ,IAAMvJ,aAAa;;;yBAG7B;;;YAULiB;0BACD,KAACnD;gBACC8K,MAAM3I;gBACNuJ,SAAS,IAAMtJ,WAAW;gBAC1Bf,UAAUA;gBACVsK,OAAO,CAAC3I,mBAAAA,WAAY,EAAE,EAAEM,GAAG,CAAC,CAACgD;wBAEbA;2BAF4B;wBAC1CvF,OAAOsD,OAAOiC,KAAKhC,GAAG;wBACtBb,OAAOY,QAAOiC,aAAAA,KAAK5C,IAAI,YAAT4C,aAAaA,KAAKhC,GAAG;oBACrC;;gBACArB,QAAQI;gBACRuI,MAAMvJ;gBACN2C,OAAOzC;gBACPsJ,UAAU,CAACpH,SAAW,KAAKD,WAAWC;;;;IAK5C,qBACE;;0BAGE,KAACzG;gBAAiBuH,OAAOlE,SAASqC,IAAI,IAAInC;;YACzC8H;;;AAGP;AACAlI,mBAAmB2K,WAAW,GAAG;AAEjC,eAAe3K,mBAAkB"}
|
|
@@ -135,3 +135,24 @@ export declare function CampaignDestinationsSection(props: CampaignReachProps):
|
|
|
135
135
|
export declare namespace CampaignDestinationsSection {
|
|
136
136
|
var displayName: string;
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).
|
|
140
|
+
*
|
|
141
|
+
* A sequence joins a campaign from its own document, and the Outreach
|
|
142
|
+
* runtime credits what each enrollment produced to the campaign's one
|
|
143
|
+
* sequences report — `campaignSequenceReports/{campaignId}` on the host,
|
|
144
|
+
* server-only like the conversions rollup. One keyed read, joined on the
|
|
145
|
+
* CONTAINER's id rather than on send ids: a sequence's emails are not the
|
|
146
|
+
* campaign's sends, so the join the three sections above make does not
|
|
147
|
+
* exist for it, and the report is written under the campaign directly.
|
|
148
|
+
*
|
|
149
|
+
* Absent is not zero: a campaign no sequence was ever in has no document,
|
|
150
|
+
* and says so in words rather than as five dashes.
|
|
151
|
+
*/
|
|
152
|
+
export declare function CampaignSequencesSection(props: {
|
|
153
|
+
hostId: string;
|
|
154
|
+
campaignId: string;
|
|
155
|
+
}): import("react").JSX.Element;
|
|
156
|
+
export declare namespace CampaignSequencesSection {
|
|
157
|
+
var displayName: string;
|
|
158
|
+
}
|
|
@@ -25,7 +25,7 @@ import { Alert, Button, Stack, TableBody, TableCell, TableHead, TableRow, Typogr
|
|
|
25
25
|
import { collection, doc, getCountFromServer, getDoc, query, where } from "firebase/firestore";
|
|
26
26
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
27
27
|
import { useFirestore } from "@aglyn/tenant-feature-instance";
|
|
28
|
-
import { campaignRevenueAcrossSends, CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, EMAIL_ATTRIBUTION_WINDOW_DAYS } from "@aglyn/shared-ui-email-campaigns/model";
|
|
28
|
+
import { campaignRevenueAcrossSends, campaignSequencesReport, CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION, EMAIL_ATTRIBUTION_WINDOW_DAYS } from "@aglyn/shared-ui-email-campaigns/model";
|
|
29
29
|
/**
|
|
30
30
|
* A CAMPAIGN BEYOND ITS MAIL — what it caused, what it earned, and where it
|
|
31
31
|
* sent people.
|
|
@@ -690,5 +690,86 @@ CampaignRevenueSection.displayName = 'CampaignRevenueSection';
|
|
|
690
690
|
});
|
|
691
691
|
}
|
|
692
692
|
CampaignDestinationsSection.displayName = 'CampaignDestinationsSection';
|
|
693
|
+
/**
|
|
694
|
+
* WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).
|
|
695
|
+
*
|
|
696
|
+
* A sequence joins a campaign from its own document, and the Outreach
|
|
697
|
+
* runtime credits what each enrollment produced to the campaign's one
|
|
698
|
+
* sequences report — `campaignSequenceReports/{campaignId}` on the host,
|
|
699
|
+
* server-only like the conversions rollup. One keyed read, joined on the
|
|
700
|
+
* CONTAINER's id rather than on send ids: a sequence's emails are not the
|
|
701
|
+
* campaign's sends, so the join the three sections above make does not
|
|
702
|
+
* exist for it, and the report is written under the campaign directly.
|
|
703
|
+
*
|
|
704
|
+
* Absent is not zero: a campaign no sequence was ever in has no document,
|
|
705
|
+
* and says so in words rather than as five dashes.
|
|
706
|
+
*/ export function CampaignSequencesSection(props) {
|
|
707
|
+
const { hostId, campaignId } = props;
|
|
708
|
+
const firestore = useFirestore();
|
|
709
|
+
const [rollup, setRollup] = useState(null);
|
|
710
|
+
const [failed, setFailed] = useState(false);
|
|
711
|
+
useEffect(()=>{
|
|
712
|
+
let active = true;
|
|
713
|
+
setRollup(null);
|
|
714
|
+
setFailed(false);
|
|
715
|
+
getDoc(doc(firestore, 'hosts', hostId, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION, campaignId)).then((snapshot)=>{
|
|
716
|
+
var _snapshot_data;
|
|
717
|
+
if (active) setRollup((_snapshot_data = snapshot.data()) != null ? _snapshot_data : undefined);
|
|
718
|
+
}).catch(()=>{
|
|
719
|
+
// Withheld, never zeroed — the conversions section's reason.
|
|
720
|
+
if (active) setFailed(true);
|
|
721
|
+
});
|
|
722
|
+
return ()=>{
|
|
723
|
+
active = false;
|
|
724
|
+
};
|
|
725
|
+
}, [
|
|
726
|
+
firestore,
|
|
727
|
+
hostId,
|
|
728
|
+
campaignId
|
|
729
|
+
]);
|
|
730
|
+
const report = rollup === null ? null : campaignSequencesReport(rollup);
|
|
731
|
+
return /*#__PURE__*/ _jsx(Section, {
|
|
732
|
+
title: "What its sequences produced",
|
|
733
|
+
children: failed ? /*#__PURE__*/ _jsx(Alert, {
|
|
734
|
+
severity: "warning",
|
|
735
|
+
children: 'What this campaign’s sequences produced could not be read.'
|
|
736
|
+
}) : !report ? /*#__PURE__*/ _jsx(Typography, {
|
|
737
|
+
variant: "body2",
|
|
738
|
+
color: "text.secondary",
|
|
739
|
+
children: 'Reading what this campaign’s sequences produced…'
|
|
740
|
+
}) : !report.recorded ? /*#__PURE__*/ _jsx(Typography, {
|
|
741
|
+
variant: "body2",
|
|
742
|
+
color: "text.secondary",
|
|
743
|
+
children: 'No sequence has been in this campaign. Put one in it from the ' + 'sequence’s settings, and everyone enrolled from then on counts here.'
|
|
744
|
+
}) : /*#__PURE__*/ _jsxs(Stack, {
|
|
745
|
+
spacing: 1,
|
|
746
|
+
children: [
|
|
747
|
+
/*#__PURE__*/ _jsx(Stack, {
|
|
748
|
+
direction: "row",
|
|
749
|
+
spacing: 3,
|
|
750
|
+
sx: {
|
|
751
|
+
flexWrap: 'wrap'
|
|
752
|
+
},
|
|
753
|
+
children: report.figures.map((figure)=>/*#__PURE__*/ _jsx(Figure, {
|
|
754
|
+
label: figure.label,
|
|
755
|
+
value: figure.value,
|
|
756
|
+
note: figure.note
|
|
757
|
+
}, figure.outcome))
|
|
758
|
+
}),
|
|
759
|
+
report.caveats.map((caveat)=>/*#__PURE__*/ _jsx(Typography, {
|
|
760
|
+
variant: "caption",
|
|
761
|
+
color: "text.secondary",
|
|
762
|
+
children: caveat.message
|
|
763
|
+
}, caveat.id)),
|
|
764
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
765
|
+
variant: "caption",
|
|
766
|
+
color: "text.secondary",
|
|
767
|
+
children: 'Credited to the campaigns a sequence was in when each person was ' + 'enrolled. A meeting counts when it was booked from a link in a ' + 'sequence email; a conversion when a lead in a sequence became a contact.'
|
|
768
|
+
})
|
|
769
|
+
]
|
|
770
|
+
})
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
CampaignSequencesSection.displayName = 'CampaignSequencesSection';
|
|
693
774
|
|
|
694
775
|
//# sourceMappingURL=campaign-reach-sections.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-reach-sections.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n MoneyFigure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Button,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n doc,\n getCountFromServer,\n getDoc,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n campaignRevenueAcrossSends,\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n type CampaignConversionKind,\n type CampaignLinkRollup,\n type CampaignRevenueAcrossSends,\n type CampaignRevenueRollup,\n} from '@aglyn/shared-ui-email-campaigns/model'\n\n/**\n * A CAMPAIGN BEYOND ITS MAIL — what it caused, what it earned, and where it\n * sent people.\n *\n * ## Attribution is the mechanism, and there is no other one\n *\n * A campaign owns no screen, no form and no overlay. Nothing in the stored\n * model connects `emailCampaigns/{id}` to a page a visitor lands on:\n * `HostExperiment` names a screen and never a campaign, `HostOverlay` names\n * neither, and a form document carries no campaign field. So a section\n * claiming a campaign CONTAINS a landing page would be inventing an edge, and\n * the containment would be a claim nothing in the data supports.\n *\n * What the data does support is EVIDENCE, of two kinds, and both are already\n * being written:\n *\n * - **Where it sent people.** `campaigns/{sendId}/reports/links` counts\n * clicks per destination URL, so the union across a campaign's emails is\n * the set of pages that campaign drove traffic to — observed rather than\n * declared, which is also why it stays true when a marketer re-points a\n * link.\n * - **What it caused there.** `campaignAttributions` records one row per\n * identify moment — a form submission, a lead, a contact, a booking —\n * carrying the SEND whose link the visitor followed.\n * - **What it earned there.** `campaigns/{sendId}/reports/revenue` buckets\n * the orders credited to that send by currency, so the merge across a\n * campaign's emails is what the campaign was credited with — per currency,\n * because the buckets have units and a total across them would not.\n *\n * All three sections below therefore hang off the campaign's own send ids,\n * which is the only handle any of those collections offers.\n *\n * ## What neither section can say, and says so instead\n *\n * A conversion credited to a WEB touch carries `utm_` labels and no send id.\n * A campaign container has no `utm_` label of its own — a marketer types\n * those into a URL — so there is no join to make, and no amount of matching\n * on the campaign's NAME would be one: two campaigns may share a name, and a\n * label is a string anybody who can vary a query string can mint. The\n * site-wide Conversions section is where the web channel is read, and the\n * copy below points at it rather than quietly leaving it out.\n *\n * A conversion with no touch at all is credited to nobody and is in neither\n * collection. The same holds for an order: one placed by somebody who never\n * clicked is credited to no campaign. That makes a page showing only credited\n * outcomes a partial account by construction, which is why the figures are\n * labelled \"credited to this campaign\" everywhere they appear.\n */\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nexport interface CampaignReachProps {\n hostId: string\n /**\n * The campaign's emails, by send id — the handle both collections join on.\n *\n * A stable array is not required: both sections key their reads on the\n * joined string, so a caller rebuilding the array every render pays for one\n * set of reads rather than one per render.\n */\n sendIds: readonly string[]\n /** The campaign holds more emails than `sendIds` names. */\n truncated: boolean\n}\n\n/** The ids as one primitive, so an effect depends on the VALUE not the array. */\nconst idsKey = (sendIds: readonly string[]): string => sendIds.join(',')\n\n/** `['a','b','c']` in runs of at most {@link ID_CHUNK}. */\nfunction chunked(ids: readonly string[]): string[][] {\n const out: string[][] = []\n for (let index = 0; index < ids.length; index += ID_CHUNK) {\n out.push(ids.slice(index, index + ID_CHUNK))\n }\n return out\n}\n\n/**\n * WHAT THE CAMPAIGN CAUSED, counted rather than listed.\n *\n * Four aggregation counts over `campaignAttributions`, one per kind, each\n * narrowed to this campaign's own sends. An aggregation is billed per\n * thousand index entries rather than per document, so the honest figure costs\n * about as much as a single row — which is what lets this run on mount\n * instead of behind a button. Listing the records would not: that is the\n * per-record read every figure on this page is arranged to avoid, and the\n * site-wide Conversions section already lists them.\n *\n * The composite index the query needs — `kind` then `campaignId` — is already\n * declared for the conversions list's own campaign-scoped view, and an `in`\n * runs as a disjunction of equalities against that same index.\n *\n * ## The kinds are never added together\n *\n * One form submission by a new person writes a submission, a contact AND a\n * lead: three true statements about one visit. A total would count that visit\n * three times and would look exactly like a bigger number, so there is no\n * total here and the note under the figures says why.\n */\nexport function CampaignConversionsSection(\n props: CampaignReachProps & {\n /** The marketing hub URL, for the site-wide conversions list. */\n basePath: string\n },\n) {\n const { hostId, sendIds, truncated, basePath } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [counts, setCounts] = useState<Record<string, number> | null>(null)\n const [failed, setFailed] = useState(false)\n\n useEffect(() => {\n const ids = key ? key.split(',') : []\n setCounts(null)\n setFailed(false)\n if (!ids.length) return\n let active = true\n const attributions = collection(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n )\n /*\n * Summed ACROSS chunks, which is safe because a record carries exactly\n * one `campaignId`: the chunks partition the send ids, so no record can\n * be counted by two of them.\n */\n void Promise.all(\n CAMPAIGN_CONVERSION_KINDS.map(async (kind) => {\n const perChunk = await Promise.all(\n chunked(ids).map((chunk) =>\n getCountFromServer(\n query(\n attributions,\n where('kind', '==', kind),\n where('campaignId', 'in', chunk),\n ),\n ).then((snapshot) => Number(snapshot.data().count ?? 0)),\n ),\n )\n return [kind, perChunk.reduce((sum, value) => sum + value, 0)] as const\n }),\n )\n .then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n .catch(() => {\n /*\n * WITHHELD, never zeroed. A count that failed and a campaign that\n * caused nothing are opposite facts, and rendering the first as the\n * second is the most flattering wrong answer available here.\n */\n if (active) setFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, hostId, key])\n\n return (\n <Section title=\"What it caused\">\n {!key ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : failed ? (\n <Alert severity=\"warning\">\n {'The conversions credited to this campaign could not be counted.'}\n </Alert>\n ) : !counts ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Counting what this campaign is credited with…'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n {CAMPAIGN_CONVERSION_KINDS.map((kind: CampaignConversionKind) => (\n <Figure\n key={kind}\n label={CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n value={counts[kind] ?? 0}\n note={CAMPAIGN_CONVERSION_KIND_COPY[kind].note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These count different things about the same visits and are ' +\n 'deliberately not added together — one person filling in one ' +\n 'form appears as a submission, a contact and a lead.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to this campaign’s own emails. Somebody who arrived ' +\n 'from a link tagged with utm_ parameters is credited to that ' +\n 'label instead, and somebody who arrived directly is credited ' +\n 'to nobody — both are counted on the site’s conversions list.'}\n </Typography>\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Over the emails listed below. This campaign has sent more ' +\n 'than the page holds, and their conversions are not in these ' +\n 'figures.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n <Stack direction=\"row\">\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n </Stack>\n </Section>\n )\n}\nCampaignConversionsSection.displayName = 'CampaignConversionsSection'\n\n/**\n * The rule the amounts were credited under, in the sentence a reader can\n * check them against.\n *\n * Plural where the campaign is plural. A container can hold emails credited\n * under different models or windows, and printing one of them as though it\n * governed all of them would be the same lie as printing one currency symbol\n * over two currencies.\n */\nfunction creditedUnder(report: CampaignRevenueAcrossSends): string {\n const model = report.models.length === 1 ? report.models[0] : ''\n const rule =\n model === 'last-click'\n ? 'to the last campaign whose link the buyer clicked'\n : model\n ? `under the ${model} model`\n : 'under more than one attribution model'\n const window =\n report.windowDays.length === 1\n ? `within ${report.windowDays[0]} days of that click`\n : `within ${report.windowDays.join(' or ')} days of that click, ` +\n 'depending on the email'\n return `Credited ${rule}, ${window}.`\n}\n\n/**\n * WHAT THE CAMPAIGN EARNED — per currency, and never across them.\n *\n * The same question a campaign of one email answers, answered the same way by\n * a campaign of several: how many messages a container happens to hold is not\n * a fact about whether its revenue is knowable. So this is the same section\n * the single-send report carries — `Revenue`, in the same place in the page's\n * order, drawn by the same money components — over a merge rather than over\n * one document.\n *\n * ## Why it takes a merge, and what the merge may not do\n *\n * `reports/revenue` is written per SEND. There is no container-level rollup\n * and there should not be one: it would have to be kept true against every\n * order and every refund of every email in the campaign, duplicating figures\n * that are already correct one level down. So the campaign's figure is one\n * document per email and an addition — and the addition is the danger.\n *\n * **Currency is to money what kind is to a conversion.** The section above\n * refuses to total form submissions with leads because one visit writes both;\n * this refuses to total USD with EUR because the sum has no unit. Both\n * refusals are structural rather than remembered:\n * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so\n * amounts in different currencies never reach the same addition, and the\n * result carries no combined field for a screen to print.\n *\n * ## One currency reads as one figure; several read as several\n *\n * The common case is one currency, and it is drawn exactly as a single\n * email's report draws it — net, gross, refunded, orders — with no currency\n * heading, because a label saying `USD` over a page that only ever shows\n * dollars is noise. The moment a second currency appears every block gets its\n * code as a heading and its own email count, and a caveat says in words what\n * the headings say in layout: these do not add up, and nothing here converts\n * between them. The rare case pays for itself; the common one does not pay\n * for the rare one.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email — the per-record read the conversions section above\n * is shaped to avoid. That section can run on mount because an aggregation is\n * billed per thousand index entries, so its four honest figures cost about as\n * much as a single row however large the campaign is. This cannot: fifty\n * emails is fifty document reads, every time the page opens, for a section\n * most readers of a campaign are not opening the page for. So it is asked\n * for, at the same control and in the same words as the destinations section\n * below, which buys its rows the same way.\n *\n * ## Nothing, and nothing readable, are different sentences\n *\n * The rollup is created by the writer on the first order it credits, so an\n * email with no record has never earned. A campaign where NO email has one\n * has not been shown to have earned nothing — it is also every campaign sent\n * before the join existed and every campaign on a site with no store, which\n * is the distinction the single-send report draws in the same two sentences.\n * A read that is REFUSED shows neither: the figures are withheld behind a\n * warning, and any figures already on screen are dropped rather than left\n * standing beside it, because a stale total under a fresh error is the one\n * arrangement that looks authoritative and is not.\n */\nexport function CampaignRevenueSection(props: CampaignReachProps) {\n const { hostId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [report, setReport] = useState<CampaignRevenueAcrossSends | null>(null)\n\n // A merge over one set of emails describes that set. Leaving it on screen\n // after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setReport(null)\n setError(null)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'revenue',\n ),\n ),\n ),\n )\n /*\n * `data()` is `undefined` on a document that does not exist, and it is\n * handed on as `undefined` rather than as an empty rollup: the merge\n * counts the records that EXIST to tell a campaign that earned nothing\n * from one whose revenue was never recorded at all.\n */\n setReport(\n campaignRevenueAcrossSends(\n snapshots.map(\n (snapshot) => snapshot.data() as CampaignRevenueRollup | undefined,\n ),\n ),\n )\n } catch (caught) {\n console.error(caught)\n /*\n * WITHHELD, never zeroed and never left stale. A refused read and a\n * campaign that earned nothing are opposite facts, and the second is\n * the more flattering of the two.\n */\n setReport(null)\n setError('The revenue credited to this campaign could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, hostId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Revenue\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'What this campaign’s emails were credited with, read from each ' +\n 'email’s own revenue record and merged by currency.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : report ? 'Read again' : 'Show revenue'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {report ? (\n report.currencies.length ? (\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {report.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one\n * and absent when there is one — the single-send report's\n * rule, so the two shapes of campaign page read alike.\n */}\n {report.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n {/*\n * Only where it says something. With one currency this\n * repeats the coverage line under the figures; with\n * two it is what shows the blocks are disjoint parts of\n * the campaign rather than two views of all of it.\n */}\n {report.multiCurrency ? (\n <Figure\n label=\"Emails\"\n value={entry.emails}\n note={`earned in ${entry.currency.toUpperCase()}`}\n />\n ) : null}\n </Stack>\n </Stack>\n ))}\n {/*\n * NO PER-MESSAGE AVERAGE, and this is where a reader of the\n * single-send report would look for one. That page divides by\n * the send's own delivered count, read from the same document\n * at the same instant. A campaign has no such pair — its\n * revenue covers the emails with a revenue record and its\n * delivery total covers the emails that recorded a delivery\n * count, which are different subsets — so the quotient would\n * be an average over a population nobody named.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${report.attributedOrders.toLocaleString()} ` +\n `${report.attributedOrders === 1 ? 'order' : 'orders'} ` +\n `credited, across ${report.recorded.toLocaleString()} of ` +\n `this campaign’s ${report.read.toLocaleString()} ` +\n `${report.read === 1 ? 'email' : 'emails'}. The rest have ` +\n 'never been credited with a sale.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${creditedUnder(report)} Clicks only — an open is not ` +\n 'treated as evidence that anybody read the email. An ' +\n 'order placed by somebody who never clicked, or who ' +\n 'checked out without giving an address, is credited to no ' +\n 'campaign, so this is a floor rather than every sale this ' +\n 'campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to any of this ' +\n 'campaign’s emails. Orders are credited to the last ' +\n 'campaign whose link the buyer clicked, within ' +\n `${EMAIL_ATTRIBUTION_WINDOW_DAYS} days — emails sent ` +\n 'before that was recorded, or a site with no store, will ' +\n 'never show a figure here.'}\n </Typography>\n )\n ) : null}\n {/*\n OUTSIDE both branches, because it qualifies both. A campaign that\n reports nothing over the emails this page holds has not reported\n nothing about itself, and the emptier answer is the one that most\n needs its population named.\n */}\n {report && truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${report.read.toLocaleString()} emails this page ` +\n 'holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignRevenueSection.displayName = 'CampaignRevenueSection'\n\n/** One destination, summed across the campaign's emails. */\ninterface DestinationRow {\n url: string\n clicks: number\n /** How many of the campaign's emails carried a link to it. */\n emails: number\n}\n\n/** What one pass over the campaign's link rollups produced. */\ninterface DestinationsResult {\n rows: DestinationRow[]\n /** Emails whose rollup was read, which is the population the rows describe. */\n read: number\n /** Clicks on destinations past each email's own rollup cap. */\n overflowClicks: number\n /** Clicks that arrived naming no destination at all. */\n unattributedClicks: number\n}\n\n/**\n * WHERE THE CAMPAIGN SENT PEOPLE — the pages its mail pointed at.\n *\n * The union of `campaigns/{sendId}/reports/links` across the campaign's\n * emails, which is the closest the stored data comes to \"the surfaces this\n * campaign runs across\". It is observed rather than declared: a page is on\n * this list because a link in this campaign's mail was followed to it.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email, up to the ceiling the page holds — which is the\n * per-record read the figures above are shaped to avoid, so it is asked for\n * rather than paid on mount. The same bargain the conversions list makes for\n * its landing-page grouping.\n *\n * ## What a row is, and what it is not\n *\n * A destination is the link's address with its query string dropped, because\n * a campaign body is merged per recipient and a personalised query would mint\n * one row per person — and could carry that person's address into an\n * aggregate the whole team reads. The cost is that two links to one page\n * distinguished only by their tracking parameters are one row, which the note\n * under the table states rather than leaving the reader to discover.\n *\n * Clicks are EVENTS. One reader clicking twice counts twice, so a destination\n * total is not a count of people.\n */\nexport function CampaignDestinationsSection(props: CampaignReachProps) {\n const { hostId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [result, setResult] = useState<DestinationsResult | null>(null)\n /*\n * The page is a SLICE of a window this section already holds. One campaign\n * can reach fifty destinations per email over fifty emails, so the row\n * count is bounded by the rollups rather than small — and a table that\n * grows with the campaign gets a footer like every other one.\n */\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const visibleRows = useMemo(\n () => (result?.rows ?? []).slice(page * pageSize, page * pageSize + pageSize),\n [result, page, pageSize],\n )\n\n // A grouping read for one set of emails describes that set. Leaving it on\n // screen after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setResult(null)\n setError(null)\n setPage(0)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'links',\n ),\n ),\n ),\n )\n const byUrl = new Map<string, DestinationRow>()\n let overflowClicks = 0\n let unattributedClicks = 0\n snapshots.forEach((snapshot) => {\n const rollup = snapshot.data() as CampaignLinkRollup | undefined\n if (!rollup) return\n overflowClicks += Number(rollup.overflowClicks ?? 0)\n unattributedClicks += Number(rollup.unattributedClicks ?? 0)\n Object.values(rollup.links ?? {}).forEach((entry) => {\n const url = String(entry?.url ?? '').trim()\n if (!url) return\n const row = byUrl.get(url) ?? { url, clicks: 0, emails: 0 }\n row.clicks += Number(entry?.clicks ?? 0)\n // One rollup holds at most one entry per destination, so this\n // counts EMAILS that linked there rather than link occurrences.\n row.emails += 1\n byUrl.set(url, row)\n })\n })\n setPage(0)\n setResult({\n rows: [...byUrl.values()].sort(\n (a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url),\n ),\n read: ids.length,\n overflowClicks,\n unattributedClicks,\n })\n } catch (caught) {\n console.error(caught)\n setError('The destinations could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, hostId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Where it sent people\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so it has sent ' +\n 'nobody anywhere yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to. A campaign ' +\n 'does not own a page — this is where its mail actually sent ' +\n 'people, read from each email’s own click report.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : result ? 'Read again' : 'Show destinations'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {result ? (\n result.rows.length ? (\n <Stack spacing={0.5}>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Emails'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {visibleRows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\">\n {row.emails.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.clicks.toLocaleString()}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={visibleRows.length}\n count={result.rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A destination is the address without its query string, ' +\n 'so two links to one page that differ only in their ' +\n 'tracking parameters are one row. Clicks are events — ' +\n 'one reader clicking twice counts twice.'}\n </Typography>\n {result.overflowClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.overflowClicks.toLocaleString()} further ` +\n 'clicks landed on destinations past the per-email cap ' +\n 'and are not in the table.'}\n </Typography>\n ) : null}\n {result.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived naming no destination, so they belong to no ' +\n 'row.'}\n </Typography>\n ) : null}\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${result.read.toLocaleString()} emails this ` +\n 'page holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'None of this campaign’s emails has recorded a followed ' +\n 'link yet.'}\n </Typography>\n )\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignDestinationsSection.displayName = 'CampaignDestinationsSection'\n"],"names":["AppLink","Figure","MoneyFigure","Section","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","collection","doc","getCountFromServer","getDoc","query","where","useCallback","useEffect","useMemo","useState","useFirestore","campaignRevenueAcrossSends","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","EMAIL_ATTRIBUTION_WINDOW_DAYS","ID_CHUNK","idsKey","sendIds","join","chunked","ids","out","index","length","push","slice","CampaignConversionsSection","props","hostId","truncated","basePath","firestore","key","counts","setCounts","failed","setFailed","split","active","attributions","Promise","all","map","kind","perChunk","chunk","then","snapshot","Number","data","count","reduce","sum","value","entries","Object","fromEntries","catch","title","variant","color","severity","spacing","direction","sx","flexWrap","label","note","component","componentVariant","nativeButton","href","size","displayName","creditedUnder","report","model","models","rule","window","windowDays","CampaignRevenueSection","busy","setBusy","error","setError","setReport","load","snapshots","id","caught","console","alignItems","onClick","disabled","toLocaleString","currencies","caveats","caveat","message","entry","multiCurrency","currency","toUpperCase","useFlexGap","cents","netCents","grossCents","refundedCents","orders","refundedOrders","emails","attributedOrders","recorded","read","CampaignDestinationsSection","result","setResult","page","setPage","pageSize","setPageSize","visibleRows","rows","byUrl","Map","overflowClicks","unattributedClicks","forEach","rollup","values","links","url","String","trim","row","get","clicks","set","sort","a","b","localeCompare","align","wordBreak","rowCount","onPageChange","onPageSizeChange"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-reach-sections.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n MoneyFigure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Button,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n doc,\n getCountFromServer,\n getDoc,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n campaignRevenueAcrossSends,\n campaignSequencesReport,\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n CAMPAIGN_SEQUENCE_REPORTS_COLLECTION,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n type CampaignConversionKind,\n type CampaignLinkRollup,\n type CampaignRevenueAcrossSends,\n type CampaignRevenueRollup,\n type CampaignSequencesRollup,\n} from '@aglyn/shared-ui-email-campaigns/model'\n\n/**\n * A CAMPAIGN BEYOND ITS MAIL — what it caused, what it earned, and where it\n * sent people.\n *\n * ## Attribution is the mechanism, and there is no other one\n *\n * A campaign owns no screen, no form and no overlay. Nothing in the stored\n * model connects `emailCampaigns/{id}` to a page a visitor lands on:\n * `HostExperiment` names a screen and never a campaign, `HostOverlay` names\n * neither, and a form document carries no campaign field. So a section\n * claiming a campaign CONTAINS a landing page would be inventing an edge, and\n * the containment would be a claim nothing in the data supports.\n *\n * What the data does support is EVIDENCE, of two kinds, and both are already\n * being written:\n *\n * - **Where it sent people.** `campaigns/{sendId}/reports/links` counts\n * clicks per destination URL, so the union across a campaign's emails is\n * the set of pages that campaign drove traffic to — observed rather than\n * declared, which is also why it stays true when a marketer re-points a\n * link.\n * - **What it caused there.** `campaignAttributions` records one row per\n * identify moment — a form submission, a lead, a contact, a booking —\n * carrying the SEND whose link the visitor followed.\n * - **What it earned there.** `campaigns/{sendId}/reports/revenue` buckets\n * the orders credited to that send by currency, so the merge across a\n * campaign's emails is what the campaign was credited with — per currency,\n * because the buckets have units and a total across them would not.\n *\n * All three sections below therefore hang off the campaign's own send ids,\n * which is the only handle any of those collections offers.\n *\n * ## What neither section can say, and says so instead\n *\n * A conversion credited to a WEB touch carries `utm_` labels and no send id.\n * A campaign container has no `utm_` label of its own — a marketer types\n * those into a URL — so there is no join to make, and no amount of matching\n * on the campaign's NAME would be one: two campaigns may share a name, and a\n * label is a string anybody who can vary a query string can mint. The\n * site-wide Conversions section is where the web channel is read, and the\n * copy below points at it rather than quietly leaving it out.\n *\n * A conversion with no touch at all is credited to nobody and is in neither\n * collection. The same holds for an order: one placed by somebody who never\n * clicked is credited to no campaign. That makes a page showing only credited\n * outcomes a partial account by construction, which is why the figures are\n * labelled \"credited to this campaign\" everywhere they appear.\n */\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nexport interface CampaignReachProps {\n hostId: string\n /**\n * The campaign's emails, by send id — the handle both collections join on.\n *\n * A stable array is not required: both sections key their reads on the\n * joined string, so a caller rebuilding the array every render pays for one\n * set of reads rather than one per render.\n */\n sendIds: readonly string[]\n /** The campaign holds more emails than `sendIds` names. */\n truncated: boolean\n}\n\n/** The ids as one primitive, so an effect depends on the VALUE not the array. */\nconst idsKey = (sendIds: readonly string[]): string => sendIds.join(',')\n\n/** `['a','b','c']` in runs of at most {@link ID_CHUNK}. */\nfunction chunked(ids: readonly string[]): string[][] {\n const out: string[][] = []\n for (let index = 0; index < ids.length; index += ID_CHUNK) {\n out.push(ids.slice(index, index + ID_CHUNK))\n }\n return out\n}\n\n/**\n * WHAT THE CAMPAIGN CAUSED, counted rather than listed.\n *\n * Four aggregation counts over `campaignAttributions`, one per kind, each\n * narrowed to this campaign's own sends. An aggregation is billed per\n * thousand index entries rather than per document, so the honest figure costs\n * about as much as a single row — which is what lets this run on mount\n * instead of behind a button. Listing the records would not: that is the\n * per-record read every figure on this page is arranged to avoid, and the\n * site-wide Conversions section already lists them.\n *\n * The composite index the query needs — `kind` then `campaignId` — is already\n * declared for the conversions list's own campaign-scoped view, and an `in`\n * runs as a disjunction of equalities against that same index.\n *\n * ## The kinds are never added together\n *\n * One form submission by a new person writes a submission, a contact AND a\n * lead: three true statements about one visit. A total would count that visit\n * three times and would look exactly like a bigger number, so there is no\n * total here and the note under the figures says why.\n */\nexport function CampaignConversionsSection(\n props: CampaignReachProps & {\n /** The marketing hub URL, for the site-wide conversions list. */\n basePath: string\n },\n) {\n const { hostId, sendIds, truncated, basePath } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [counts, setCounts] = useState<Record<string, number> | null>(null)\n const [failed, setFailed] = useState(false)\n\n useEffect(() => {\n const ids = key ? key.split(',') : []\n setCounts(null)\n setFailed(false)\n if (!ids.length) return\n let active = true\n const attributions = collection(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n )\n /*\n * Summed ACROSS chunks, which is safe because a record carries exactly\n * one `campaignId`: the chunks partition the send ids, so no record can\n * be counted by two of them.\n */\n void Promise.all(\n CAMPAIGN_CONVERSION_KINDS.map(async (kind) => {\n const perChunk = await Promise.all(\n chunked(ids).map((chunk) =>\n getCountFromServer(\n query(\n attributions,\n where('kind', '==', kind),\n where('campaignId', 'in', chunk),\n ),\n ).then((snapshot) => Number(snapshot.data().count ?? 0)),\n ),\n )\n return [kind, perChunk.reduce((sum, value) => sum + value, 0)] as const\n }),\n )\n .then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n .catch(() => {\n /*\n * WITHHELD, never zeroed. A count that failed and a campaign that\n * caused nothing are opposite facts, and rendering the first as the\n * second is the most flattering wrong answer available here.\n */\n if (active) setFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, hostId, key])\n\n return (\n <Section title=\"What it caused\">\n {!key ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : failed ? (\n <Alert severity=\"warning\">\n {'The conversions credited to this campaign could not be counted.'}\n </Alert>\n ) : !counts ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Counting what this campaign is credited with…'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n {CAMPAIGN_CONVERSION_KINDS.map((kind: CampaignConversionKind) => (\n <Figure\n key={kind}\n label={CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n value={counts[kind] ?? 0}\n note={CAMPAIGN_CONVERSION_KIND_COPY[kind].note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These count different things about the same visits and are ' +\n 'deliberately not added together — one person filling in one ' +\n 'form appears as a submission, a contact and a lead.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to this campaign’s own emails. Somebody who arrived ' +\n 'from a link tagged with utm_ parameters is credited to that ' +\n 'label instead, and somebody who arrived directly is credited ' +\n 'to nobody — both are counted on the site’s conversions list.'}\n </Typography>\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Over the emails listed below. This campaign has sent more ' +\n 'than the page holds, and their conversions are not in these ' +\n 'figures.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n <Stack direction=\"row\">\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n </Stack>\n </Section>\n )\n}\nCampaignConversionsSection.displayName = 'CampaignConversionsSection'\n\n/**\n * The rule the amounts were credited under, in the sentence a reader can\n * check them against.\n *\n * Plural where the campaign is plural. A container can hold emails credited\n * under different models or windows, and printing one of them as though it\n * governed all of them would be the same lie as printing one currency symbol\n * over two currencies.\n */\nfunction creditedUnder(report: CampaignRevenueAcrossSends): string {\n const model = report.models.length === 1 ? report.models[0] : ''\n const rule =\n model === 'last-click'\n ? 'to the last campaign whose link the buyer clicked'\n : model\n ? `under the ${model} model`\n : 'under more than one attribution model'\n const window =\n report.windowDays.length === 1\n ? `within ${report.windowDays[0]} days of that click`\n : `within ${report.windowDays.join(' or ')} days of that click, ` +\n 'depending on the email'\n return `Credited ${rule}, ${window}.`\n}\n\n/**\n * WHAT THE CAMPAIGN EARNED — per currency, and never across them.\n *\n * The same question a campaign of one email answers, answered the same way by\n * a campaign of several: how many messages a container happens to hold is not\n * a fact about whether its revenue is knowable. So this is the same section\n * the single-send report carries — `Revenue`, in the same place in the page's\n * order, drawn by the same money components — over a merge rather than over\n * one document.\n *\n * ## Why it takes a merge, and what the merge may not do\n *\n * `reports/revenue` is written per SEND. There is no container-level rollup\n * and there should not be one: it would have to be kept true against every\n * order and every refund of every email in the campaign, duplicating figures\n * that are already correct one level down. So the campaign's figure is one\n * document per email and an addition — and the addition is the danger.\n *\n * **Currency is to money what kind is to a conversion.** The section above\n * refuses to total form submissions with leads because one visit writes both;\n * this refuses to total USD with EUR because the sum has no unit. Both\n * refusals are structural rather than remembered:\n * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so\n * amounts in different currencies never reach the same addition, and the\n * result carries no combined field for a screen to print.\n *\n * ## One currency reads as one figure; several read as several\n *\n * The common case is one currency, and it is drawn exactly as a single\n * email's report draws it — net, gross, refunded, orders — with no currency\n * heading, because a label saying `USD` over a page that only ever shows\n * dollars is noise. The moment a second currency appears every block gets its\n * code as a heading and its own email count, and a caveat says in words what\n * the headings say in layout: these do not add up, and nothing here converts\n * between them. The rare case pays for itself; the common one does not pay\n * for the rare one.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email — the per-record read the conversions section above\n * is shaped to avoid. That section can run on mount because an aggregation is\n * billed per thousand index entries, so its four honest figures cost about as\n * much as a single row however large the campaign is. This cannot: fifty\n * emails is fifty document reads, every time the page opens, for a section\n * most readers of a campaign are not opening the page for. So it is asked\n * for, at the same control and in the same words as the destinations section\n * below, which buys its rows the same way.\n *\n * ## Nothing, and nothing readable, are different sentences\n *\n * The rollup is created by the writer on the first order it credits, so an\n * email with no record has never earned. A campaign where NO email has one\n * has not been shown to have earned nothing — it is also every campaign sent\n * before the join existed and every campaign on a site with no store, which\n * is the distinction the single-send report draws in the same two sentences.\n * A read that is REFUSED shows neither: the figures are withheld behind a\n * warning, and any figures already on screen are dropped rather than left\n * standing beside it, because a stale total under a fresh error is the one\n * arrangement that looks authoritative and is not.\n */\nexport function CampaignRevenueSection(props: CampaignReachProps) {\n const { hostId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [report, setReport] = useState<CampaignRevenueAcrossSends | null>(null)\n\n // A merge over one set of emails describes that set. Leaving it on screen\n // after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setReport(null)\n setError(null)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'revenue',\n ),\n ),\n ),\n )\n /*\n * `data()` is `undefined` on a document that does not exist, and it is\n * handed on as `undefined` rather than as an empty rollup: the merge\n * counts the records that EXIST to tell a campaign that earned nothing\n * from one whose revenue was never recorded at all.\n */\n setReport(\n campaignRevenueAcrossSends(\n snapshots.map(\n (snapshot) => snapshot.data() as CampaignRevenueRollup | undefined,\n ),\n ),\n )\n } catch (caught) {\n console.error(caught)\n /*\n * WITHHELD, never zeroed and never left stale. A refused read and a\n * campaign that earned nothing are opposite facts, and the second is\n * the more flattering of the two.\n */\n setReport(null)\n setError('The revenue credited to this campaign could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, hostId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Revenue\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'What this campaign’s emails were credited with, read from each ' +\n 'email’s own revenue record and merged by currency.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : report ? 'Read again' : 'Show revenue'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {report ? (\n report.currencies.length ? (\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {report.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one\n * and absent when there is one — the single-send report's\n * rule, so the two shapes of campaign page read alike.\n */}\n {report.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n {/*\n * Only where it says something. With one currency this\n * repeats the coverage line under the figures; with\n * two it is what shows the blocks are disjoint parts of\n * the campaign rather than two views of all of it.\n */}\n {report.multiCurrency ? (\n <Figure\n label=\"Emails\"\n value={entry.emails}\n note={`earned in ${entry.currency.toUpperCase()}`}\n />\n ) : null}\n </Stack>\n </Stack>\n ))}\n {/*\n * NO PER-MESSAGE AVERAGE, and this is where a reader of the\n * single-send report would look for one. That page divides by\n * the send's own delivered count, read from the same document\n * at the same instant. A campaign has no such pair — its\n * revenue covers the emails with a revenue record and its\n * delivery total covers the emails that recorded a delivery\n * count, which are different subsets — so the quotient would\n * be an average over a population nobody named.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${report.attributedOrders.toLocaleString()} ` +\n `${report.attributedOrders === 1 ? 'order' : 'orders'} ` +\n `credited, across ${report.recorded.toLocaleString()} of ` +\n `this campaign’s ${report.read.toLocaleString()} ` +\n `${report.read === 1 ? 'email' : 'emails'}. The rest have ` +\n 'never been credited with a sale.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${creditedUnder(report)} Clicks only — an open is not ` +\n 'treated as evidence that anybody read the email. An ' +\n 'order placed by somebody who never clicked, or who ' +\n 'checked out without giving an address, is credited to no ' +\n 'campaign, so this is a floor rather than every sale this ' +\n 'campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to any of this ' +\n 'campaign’s emails. Orders are credited to the last ' +\n 'campaign whose link the buyer clicked, within ' +\n `${EMAIL_ATTRIBUTION_WINDOW_DAYS} days — emails sent ` +\n 'before that was recorded, or a site with no store, will ' +\n 'never show a figure here.'}\n </Typography>\n )\n ) : null}\n {/*\n OUTSIDE both branches, because it qualifies both. A campaign that\n reports nothing over the emails this page holds has not reported\n nothing about itself, and the emptier answer is the one that most\n needs its population named.\n */}\n {report && truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${report.read.toLocaleString()} emails this page ` +\n 'holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignRevenueSection.displayName = 'CampaignRevenueSection'\n\n/** One destination, summed across the campaign's emails. */\ninterface DestinationRow {\n url: string\n clicks: number\n /** How many of the campaign's emails carried a link to it. */\n emails: number\n}\n\n/** What one pass over the campaign's link rollups produced. */\ninterface DestinationsResult {\n rows: DestinationRow[]\n /** Emails whose rollup was read, which is the population the rows describe. */\n read: number\n /** Clicks on destinations past each email's own rollup cap. */\n overflowClicks: number\n /** Clicks that arrived naming no destination at all. */\n unattributedClicks: number\n}\n\n/**\n * WHERE THE CAMPAIGN SENT PEOPLE — the pages its mail pointed at.\n *\n * The union of `campaigns/{sendId}/reports/links` across the campaign's\n * emails, which is the closest the stored data comes to \"the surfaces this\n * campaign runs across\". It is observed rather than declared: a page is on\n * this list because a link in this campaign's mail was followed to it.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email, up to the ceiling the page holds — which is the\n * per-record read the figures above are shaped to avoid, so it is asked for\n * rather than paid on mount. The same bargain the conversions list makes for\n * its landing-page grouping.\n *\n * ## What a row is, and what it is not\n *\n * A destination is the link's address with its query string dropped, because\n * a campaign body is merged per recipient and a personalised query would mint\n * one row per person — and could carry that person's address into an\n * aggregate the whole team reads. The cost is that two links to one page\n * distinguished only by their tracking parameters are one row, which the note\n * under the table states rather than leaving the reader to discover.\n *\n * Clicks are EVENTS. One reader clicking twice counts twice, so a destination\n * total is not a count of people.\n */\nexport function CampaignDestinationsSection(props: CampaignReachProps) {\n const { hostId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [result, setResult] = useState<DestinationsResult | null>(null)\n /*\n * The page is a SLICE of a window this section already holds. One campaign\n * can reach fifty destinations per email over fifty emails, so the row\n * count is bounded by the rollups rather than small — and a table that\n * grows with the campaign gets a footer like every other one.\n */\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const visibleRows = useMemo(\n () => (result?.rows ?? []).slice(page * pageSize, page * pageSize + pageSize),\n [result, page, pageSize],\n )\n\n // A grouping read for one set of emails describes that set. Leaving it on\n // screen after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setResult(null)\n setError(null)\n setPage(0)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'links',\n ),\n ),\n ),\n )\n const byUrl = new Map<string, DestinationRow>()\n let overflowClicks = 0\n let unattributedClicks = 0\n snapshots.forEach((snapshot) => {\n const rollup = snapshot.data() as CampaignLinkRollup | undefined\n if (!rollup) return\n overflowClicks += Number(rollup.overflowClicks ?? 0)\n unattributedClicks += Number(rollup.unattributedClicks ?? 0)\n Object.values(rollup.links ?? {}).forEach((entry) => {\n const url = String(entry?.url ?? '').trim()\n if (!url) return\n const row = byUrl.get(url) ?? { url, clicks: 0, emails: 0 }\n row.clicks += Number(entry?.clicks ?? 0)\n // One rollup holds at most one entry per destination, so this\n // counts EMAILS that linked there rather than link occurrences.\n row.emails += 1\n byUrl.set(url, row)\n })\n })\n setPage(0)\n setResult({\n rows: [...byUrl.values()].sort(\n (a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url),\n ),\n read: ids.length,\n overflowClicks,\n unattributedClicks,\n })\n } catch (caught) {\n console.error(caught)\n setError('The destinations could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, hostId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Where it sent people\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so it has sent ' +\n 'nobody anywhere yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to. A campaign ' +\n 'does not own a page — this is where its mail actually sent ' +\n 'people, read from each email’s own click report.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : result ? 'Read again' : 'Show destinations'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {result ? (\n result.rows.length ? (\n <Stack spacing={0.5}>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Emails'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {visibleRows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\">\n {row.emails.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.clicks.toLocaleString()}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={visibleRows.length}\n count={result.rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A destination is the address without its query string, ' +\n 'so two links to one page that differ only in their ' +\n 'tracking parameters are one row. Clicks are events — ' +\n 'one reader clicking twice counts twice.'}\n </Typography>\n {result.overflowClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.overflowClicks.toLocaleString()} further ` +\n 'clicks landed on destinations past the per-email cap ' +\n 'and are not in the table.'}\n </Typography>\n ) : null}\n {result.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived naming no destination, so they belong to no ' +\n 'row.'}\n </Typography>\n ) : null}\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${result.read.toLocaleString()} emails this ` +\n 'page holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'None of this campaign’s emails has recorded a followed ' +\n 'link yet.'}\n </Typography>\n )\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignDestinationsSection.displayName = 'CampaignDestinationsSection'\n\n/**\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign from its own document, and the Outreach\n * runtime credits what each enrollment produced to the campaign's one\n * sequences report — `campaignSequenceReports/{campaignId}` on the host,\n * server-only like the conversions rollup. One keyed read, joined on the\n * CONTAINER's id rather than on send ids: a sequence's emails are not the\n * campaign's sends, so the join the three sections above make does not\n * exist for it, and the report is written under the campaign directly.\n *\n * Absent is not zero: a campaign no sequence was ever in has no document,\n * and says so in words rather than as five dashes.\n */\nexport function CampaignSequencesSection(props: { hostId: string; campaignId: string }) {\n const { hostId, campaignId } = props\n const firestore = useFirestore()\n const [rollup, setRollup] = useState<CampaignSequencesRollup | undefined | null>(null)\n const [failed, setFailed] = useState(false)\n\n useEffect(() => {\n let active = true\n setRollup(null)\n setFailed(false)\n getDoc(doc(firestore, 'hosts', hostId, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION, campaignId))\n .then((snapshot) => {\n if (active) setRollup((snapshot.data() as CampaignSequencesRollup | undefined) ?? undefined)\n })\n .catch(() => {\n // Withheld, never zeroed — the conversions section's reason.\n if (active) setFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, hostId, campaignId])\n\n const report = rollup === null ? null : campaignSequencesReport(rollup)\n\n return (\n <Section title=\"What its sequences produced\">\n {failed ? (\n <Alert severity=\"warning\">{'What this campaign’s sequences produced could not be read.'}</Alert>\n ) : !report ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Reading what this campaign’s sequences produced…'}\n </Typography>\n ) : !report.recorded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No sequence has been in this campaign. Put one in it from the ' +\n 'sequence’s settings, and everyone enrolled from then on counts here.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n {report.figures.map((figure) => (\n <Figure key={figure.outcome} label={figure.label} value={figure.value} note={figure.note} />\n ))}\n </Stack>\n {report.caveats.map((caveat) => (\n <Typography key={caveat.id} variant=\"caption\" color=\"text.secondary\">\n {caveat.message}\n </Typography>\n ))}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to the campaigns a sequence was in when each person was ' +\n 'enrolled. A meeting counts when it was booked from a link in a ' +\n 'sequence email; a conversion when a lead in a sequence became a contact.'}\n </Typography>\n </Stack>\n )}\n </Section>\n )\n}\nCampaignSequencesSection.displayName = 'CampaignSequencesSection'\n"],"names":["AppLink","Figure","MoneyFigure","Section","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","collection","doc","getCountFromServer","getDoc","query","where","useCallback","useEffect","useMemo","useState","useFirestore","campaignRevenueAcrossSends","campaignSequencesReport","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","EMAIL_ATTRIBUTION_WINDOW_DAYS","ID_CHUNK","idsKey","sendIds","join","chunked","ids","out","index","length","push","slice","CampaignConversionsSection","props","hostId","truncated","basePath","firestore","key","counts","setCounts","failed","setFailed","split","active","attributions","Promise","all","map","kind","perChunk","chunk","then","snapshot","Number","data","count","reduce","sum","value","entries","Object","fromEntries","catch","title","variant","color","severity","spacing","direction","sx","flexWrap","label","note","component","componentVariant","nativeButton","href","size","displayName","creditedUnder","report","model","models","rule","window","windowDays","CampaignRevenueSection","busy","setBusy","error","setError","setReport","load","snapshots","id","caught","console","alignItems","onClick","disabled","toLocaleString","currencies","caveats","caveat","message","entry","multiCurrency","currency","toUpperCase","useFlexGap","cents","netCents","grossCents","refundedCents","orders","refundedOrders","emails","attributedOrders","recorded","read","CampaignDestinationsSection","result","setResult","page","setPage","pageSize","setPageSize","visibleRows","rows","byUrl","Map","overflowClicks","unattributedClicks","forEach","rollup","values","links","url","String","trim","row","get","clicks","set","sort","a","b","localeCompare","align","wordBreak","rowCount","onPageChange","onPageSizeChange","CampaignSequencesSection","campaignId","setRollup","undefined","figures","figure","outcome"],"mappings":"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"}
|
|
@@ -29,12 +29,14 @@ import { type CampaignConversionKind } from '@aglyn/shared-ui-email-campaigns/mo
|
|
|
29
29
|
* opposite facts, and a component that reached for a default would print the
|
|
30
30
|
* second when it means the first.
|
|
31
31
|
*
|
|
32
|
-
* ## The
|
|
32
|
+
* ## The channels do not render the same, because they are not the same
|
|
33
33
|
*
|
|
34
34
|
* An EMAIL touch names a campaign document, so the label is a link to it. A
|
|
35
35
|
* WEB touch names `utm_` text a marketer typed into a URL — there is no
|
|
36
36
|
* document at the other end and no page to open — so it is rendered as text.
|
|
37
|
-
* A link that resolves nowhere is worse than no link.
|
|
37
|
+
* A link that resolves nowhere is worse than no link. A SEQUENCE touch
|
|
38
|
+
* (AGL-3254) names the campaign a rep's sequence is in, a container with a
|
|
39
|
+
* page, so it links like an email touch and says which door it came through.
|
|
38
40
|
*/
|
|
39
41
|
export interface ConversionAttributionProps {
|
|
40
42
|
hostId: string;
|
|
@@ -77,7 +77,8 @@ export function ConversionAttribution(props) {
|
|
|
77
77
|
const converted = Number((_record_convertedAtMs = record.convertedAtMs) != null ? _record_convertedAtMs : 0);
|
|
78
78
|
const touched = Number((_record_touchedAtMs = record.touchedAtMs) != null ? _record_touchedAtMs : 0);
|
|
79
79
|
const isEmail = record.channel === 'email';
|
|
80
|
-
const
|
|
80
|
+
const isSequence = record.channel === 'sequence';
|
|
81
|
+
const campaignHref = (isEmail || isSequence) && marketingBasePath && record.campaignId ? `${marketingBasePath}/campaigns/${record.campaignId}` : undefined;
|
|
81
82
|
return /*#__PURE__*/ _jsxs(Stack, {
|
|
82
83
|
spacing: 0.5,
|
|
83
84
|
children: [
|
|
@@ -97,9 +98,9 @@ export function ConversionAttribution(props) {
|
|
|
97
98
|
children: [
|
|
98
99
|
/*#__PURE__*/ _jsx(Chip, {
|
|
99
100
|
size: "small",
|
|
100
|
-
color: isEmail ? 'primary' : 'default',
|
|
101
|
-
variant: isEmail ? 'filled' : 'outlined',
|
|
102
|
-
label: isEmail ? 'Campaign email' : 'Web link'
|
|
101
|
+
color: isEmail || isSequence ? 'primary' : 'default',
|
|
102
|
+
variant: isEmail || isSequence ? 'filled' : 'outlined',
|
|
103
|
+
label: isEmail ? 'Campaign email' : isSequence ? 'Sequence' : 'Web link'
|
|
103
104
|
}),
|
|
104
105
|
campaignHref ? /*#__PURE__*/ _jsx(AppLink, {
|
|
105
106
|
href: campaignHref,
|
|
@@ -116,7 +117,7 @@ export function ConversionAttribution(props) {
|
|
|
116
117
|
/*#__PURE__*/ _jsx(Typography, {
|
|
117
118
|
variant: "caption",
|
|
118
119
|
color: "text.secondary",
|
|
119
|
-
children: (touched ? `Followed the link on ${new Date(touched).toLocaleString()}` : 'Followed a campaign link') + (converted ? `, converted ${new Date(converted).toLocaleString()}` : '') + `. Credited ${record.model === 'last-click' ? 'to the last campaign whose link they clicked' : `under the ${String((_record_model = record.model) != null ? _record_model : 'recorded')} model`}, within ${Number((_record_windowDays = record.windowDays) != null ? _record_windowDays : 0) || 7} days of that click.`
|
|
120
|
+
children: (isSequence ? touched ? `Reached by a sequence email on ${new Date(touched).toLocaleString()}` : 'Reached by a sequence email' : touched ? `Followed the link on ${new Date(touched).toLocaleString()}` : 'Followed a campaign link') + (converted ? `, converted ${new Date(converted).toLocaleString()}` : '') + `. Credited ${record.model === 'last-click' ? 'to the last campaign whose link they clicked' : `under the ${String((_record_model = record.model) != null ? _record_model : 'recorded')} model`}, within ${Number((_record_windowDays = record.windowDays) != null ? _record_windowDays : 0) || 7} days of that click.`
|
|
120
121
|
})
|
|
121
122
|
]
|
|
122
123
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/conversion-attribution.component.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { useMarketingHubPath } from './use-emails-hub-path'\nimport {\n campaignConversionId,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n\n/**\n * WHERE THIS RECORD CAME FROM — attribution on the converted record itself.\n *\n * The campaign's own page answers \"what did this campaign cause\". This\n * answers the question from the other end, which is the one a person standing\n * in front of a lead actually has: where did THIS come from. Without it the\n * join is invisible to everybody who is not already looking at a campaign.\n *\n * ## One keyed read, and only where the record is opened\n *\n * The record's id IS half the attribution's id — `{kind}:{refId}` — so this\n * is `getDoc` on a known path: no query, no index, nothing that can be\n * truncated, and no listener over a collection. Mounted on a DETAIL surface\n * (a reader dialog, a drawer) rather than in a table row, because a column\n * would multiply that one read by the page size and charge it to every reader\n * who opened the list for another reason entirely.\n *\n * ## Absence is a real answer and is rendered as one\n *\n * The join writes nothing at all for a conversion it cannot credit — no\n * `utm_source=direct` placeholder, no referrer inference, no fallback to\n * whichever campaign ran most recently. So a missing document is the ordinary\n * case and it means \"this person arrived without following a campaign link\",\n * which this says in words.\n *\n * What it must never render is a campaign with a zero beside it. \"Came from\n * no campaign\" and \"came from this campaign, which produced nothing\" are\n * opposite facts, and a component that reached for a default would print the\n * second when it means the first.\n *\n * ## The two channels do not render the same, because they are not the same\n *\n * An EMAIL touch names a campaign document, so the label is a link to it. A\n * WEB touch names `utm_` text a marketer typed into a URL — there is no\n * document at the other end and no page to open — so it is rendered as text.\n * A link that resolves nowhere is worse than no link.\n */\nexport interface ConversionAttributionProps {\n hostId: string\n /** Which identify moment this record is. */\n kind: CampaignConversionKind\n /** The record's own document id — the `refId` the writer credited. */\n refId: string\n /**\n * The marketing hub URL, when the caller already holds it.\n *\n * Resolved from the console route when it does not, which is the ordinary\n * case: the surfaces that render this belong to other plugins — the Inbox,\n * Contacts — and they are handed their OWN hub's path. Deriving one hub's\n * URL from another's by string surgery is the link that breaks silently\n * when a surface moves.\n */\n marketingBasePath?: string\n /** Rendered instead of the \"no campaign\" sentence, for a compact surface. */\n quiet?: boolean\n}\n\nexport function ConversionAttribution(props: ConversionAttributionProps) {\n const { hostId, kind, refId, quiet } = props\n const firestore = useFirestore()\n /*\n * Free — the org slug and the subdomain are already in the URL this console\n * is on, so no host document is resolved to render a link. `null` until the\n * params settle, and the campaign is then NAMED but not linked, which is\n * the rule the web channel follows for the same reason: no link beats one\n * that resolves nowhere.\n */\n const resolvedHub = useMarketingHubPath()\n const marketingBasePath = props.marketingBasePath ?? resolvedHub\n\n /*\n * The id is BUILT rather than interpolated, so a ref carrying a slash or a\n * colon answers null here instead of naming a document in another\n * collection. A null builder is how this hook is told not to read at all.\n */\n const attributionId = campaignConversionId(kind, refId)\n const { data: record, status } = useFirestoreDoc<CampaignConversionRecord>(\n () =>\n attributionId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n attributionId,\n )\n : null,\n [firestore, hostId, attributionId],\n )\n\n /*\n * An id that could not be built means the record's own id is unusable, so\n * the question was never asked. Drawing the \"not credited\" sentence here\n * would state a fact nothing checked — the one thing this component must\n * not do — so it draws nothing.\n */\n if (!attributionId) return null\n\n /*\n * Nothing while the read settles. Rendering \"no campaign\" first and\n * replacing it a tick later states the opposite of the truth for as long as\n * the reader's eye takes to reach it, and this sits inside a dialog that\n * opens with the read.\n */\n if (status === 'loading') return null\n\n if (!record) {\n if (quiet) return null\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {/*\n NOT a zero, and not an empty campaign chip. The sentence says which\n fact this is: nobody was credited, and nothing was guessed.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Not credited to a campaign — this arrived directly, or from a ' +\n 'link that carried no campaign. Nothing is inferred from a ' +\n 'referrer and no campaign is credited for being the most recent ' +\n 'one to run.'}\n </Typography>\n </Stack>\n )\n }\n\n const label = campaignTouchLabel(record)\n const converted = Number(record.convertedAtMs ?? 0)\n const touched = Number(record.touchedAtMs ?? 0)\n const isEmail = record.channel === 'email'\n const campaignHref =\n isEmail && marketingBasePath && record.campaignId\n ? `${marketingBasePath}/campaigns/${record.campaignId}`\n : undefined\n\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The CHANNEL is on screen beside the label, because the two are read\n differently: one names a campaign whose report can be opened, the\n other names text somebody typed into a URL.\n */}\n <Chip\n size=\"small\"\n color={isEmail ? 'primary' : 'default'}\n variant={isEmail ? 'filled' : 'outlined'}\n label={isEmail ? 'Campaign email' : 'Web link'}\n />\n {campaignHref ? (\n <AppLink href={campaignHref}>{label || record.campaignId}</AppLink>\n ) : (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {label || 'unnamed'}\n </Typography>\n )}\n </Stack>\n {/*\n THE RULE, under the claim. A credit whose rule the reader cannot state\n is a credit they will use anyway, and the two things they need are\n which touch wins and how long one stays creditable. `model` and\n `windowDays` come off the RECORD, so a conversion credited under an\n older rule prints the rule it was credited under.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {(touched\n ? `Followed the link on ${new Date(touched).toLocaleString()}`\n : 'Followed a campaign link') +\n (converted\n ? `, converted ${new Date(converted).toLocaleString()}`\n : '') +\n `. Credited ${\n record.model === 'last-click'\n ? 'to the last campaign whose link they clicked'\n : `under the ${String(record.model ?? 'recorded')} model`\n }, within ${Number(record.windowDays ?? 0) || 7} days of that click.`}\n </Typography>\n </Stack>\n )\n}\nConversionAttribution.displayName = 'ConversionAttribution'\n\nexport default ConversionAttribution\n"],"names":["AppLink","Chip","Stack","Typography","doc","useFirestore","useFirestoreDoc","useMarketingHubPath","campaignConversionId","campaignTouchLabel","ConversionAttribution","props","record","hostId","kind","refId","quiet","firestore","resolvedHub","marketingBasePath","attributionId","data","status","spacing","variant","color","label","converted","Number","convertedAtMs","touched","touchedAtMs","isEmail","channel","campaignHref","campaignId","undefined","direction","useFlexGap","sx","alignItems","flexWrap","size","href","fontWeight","Date","toLocaleString","model","String","windowDays","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/conversion-attribution.component.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { useMarketingHubPath } from './use-emails-hub-path'\nimport {\n campaignConversionId,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n\n/**\n * WHERE THIS RECORD CAME FROM — attribution on the converted record itself.\n *\n * The campaign's own page answers \"what did this campaign cause\". This\n * answers the question from the other end, which is the one a person standing\n * in front of a lead actually has: where did THIS come from. Without it the\n * join is invisible to everybody who is not already looking at a campaign.\n *\n * ## One keyed read, and only where the record is opened\n *\n * The record's id IS half the attribution's id — `{kind}:{refId}` — so this\n * is `getDoc` on a known path: no query, no index, nothing that can be\n * truncated, and no listener over a collection. Mounted on a DETAIL surface\n * (a reader dialog, a drawer) rather than in a table row, because a column\n * would multiply that one read by the page size and charge it to every reader\n * who opened the list for another reason entirely.\n *\n * ## Absence is a real answer and is rendered as one\n *\n * The join writes nothing at all for a conversion it cannot credit — no\n * `utm_source=direct` placeholder, no referrer inference, no fallback to\n * whichever campaign ran most recently. So a missing document is the ordinary\n * case and it means \"this person arrived without following a campaign link\",\n * which this says in words.\n *\n * What it must never render is a campaign with a zero beside it. \"Came from\n * no campaign\" and \"came from this campaign, which produced nothing\" are\n * opposite facts, and a component that reached for a default would print the\n * second when it means the first.\n *\n * ## The channels do not render the same, because they are not the same\n *\n * An EMAIL touch names a campaign document, so the label is a link to it. A\n * WEB touch names `utm_` text a marketer typed into a URL — there is no\n * document at the other end and no page to open — so it is rendered as text.\n * A link that resolves nowhere is worse than no link. A SEQUENCE touch\n * (AGL-3254) names the campaign a rep's sequence is in, a container with a\n * page, so it links like an email touch and says which door it came through.\n */\nexport interface ConversionAttributionProps {\n hostId: string\n /** Which identify moment this record is. */\n kind: CampaignConversionKind\n /** The record's own document id — the `refId` the writer credited. */\n refId: string\n /**\n * The marketing hub URL, when the caller already holds it.\n *\n * Resolved from the console route when it does not, which is the ordinary\n * case: the surfaces that render this belong to other plugins — the Inbox,\n * Contacts — and they are handed their OWN hub's path. Deriving one hub's\n * URL from another's by string surgery is the link that breaks silently\n * when a surface moves.\n */\n marketingBasePath?: string\n /** Rendered instead of the \"no campaign\" sentence, for a compact surface. */\n quiet?: boolean\n}\n\nexport function ConversionAttribution(props: ConversionAttributionProps) {\n const { hostId, kind, refId, quiet } = props\n const firestore = useFirestore()\n /*\n * Free — the org slug and the subdomain are already in the URL this console\n * is on, so no host document is resolved to render a link. `null` until the\n * params settle, and the campaign is then NAMED but not linked, which is\n * the rule the web channel follows for the same reason: no link beats one\n * that resolves nowhere.\n */\n const resolvedHub = useMarketingHubPath()\n const marketingBasePath = props.marketingBasePath ?? resolvedHub\n\n /*\n * The id is BUILT rather than interpolated, so a ref carrying a slash or a\n * colon answers null here instead of naming a document in another\n * collection. A null builder is how this hook is told not to read at all.\n */\n const attributionId = campaignConversionId(kind, refId)\n const { data: record, status } = useFirestoreDoc<CampaignConversionRecord>(\n () =>\n attributionId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n attributionId,\n )\n : null,\n [firestore, hostId, attributionId],\n )\n\n /*\n * An id that could not be built means the record's own id is unusable, so\n * the question was never asked. Drawing the \"not credited\" sentence here\n * would state a fact nothing checked — the one thing this component must\n * not do — so it draws nothing.\n */\n if (!attributionId) return null\n\n /*\n * Nothing while the read settles. Rendering \"no campaign\" first and\n * replacing it a tick later states the opposite of the truth for as long as\n * the reader's eye takes to reach it, and this sits inside a dialog that\n * opens with the read.\n */\n if (status === 'loading') return null\n\n if (!record) {\n if (quiet) return null\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {/*\n NOT a zero, and not an empty campaign chip. The sentence says which\n fact this is: nobody was credited, and nothing was guessed.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Not credited to a campaign — this arrived directly, or from a ' +\n 'link that carried no campaign. Nothing is inferred from a ' +\n 'referrer and no campaign is credited for being the most recent ' +\n 'one to run.'}\n </Typography>\n </Stack>\n )\n }\n\n const label = campaignTouchLabel(record)\n const converted = Number(record.convertedAtMs ?? 0)\n const touched = Number(record.touchedAtMs ?? 0)\n const isEmail = record.channel === 'email'\n const isSequence = record.channel === 'sequence'\n const campaignHref =\n (isEmail || isSequence) && marketingBasePath && record.campaignId\n ? `${marketingBasePath}/campaigns/${record.campaignId}`\n : undefined\n\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The CHANNEL is on screen beside the label, because the two are read\n differently: one names a campaign whose report can be opened, the\n other names text somebody typed into a URL.\n */}\n <Chip\n size=\"small\"\n color={isEmail || isSequence ? 'primary' : 'default'}\n variant={isEmail || isSequence ? 'filled' : 'outlined'}\n label={isEmail ? 'Campaign email' : isSequence ? 'Sequence' : 'Web link'}\n />\n {campaignHref ? (\n <AppLink href={campaignHref}>{label || record.campaignId}</AppLink>\n ) : (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {label || 'unnamed'}\n </Typography>\n )}\n </Stack>\n {/*\n THE RULE, under the claim. A credit whose rule the reader cannot state\n is a credit they will use anyway, and the two things they need are\n which touch wins and how long one stays creditable. `model` and\n `windowDays` come off the RECORD, so a conversion credited under an\n older rule prints the rule it was credited under.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {(isSequence\n ? touched\n ? `Reached by a sequence email on ${new Date(touched).toLocaleString()}`\n : 'Reached by a sequence email'\n : touched\n ? `Followed the link on ${new Date(touched).toLocaleString()}`\n : 'Followed a campaign link') +\n (converted\n ? `, converted ${new Date(converted).toLocaleString()}`\n : '') +\n `. Credited ${\n record.model === 'last-click'\n ? 'to the last campaign whose link they clicked'\n : `under the ${String(record.model ?? 'recorded')} model`\n }, within ${Number(record.windowDays ?? 0) || 7} days of that click.`}\n </Typography>\n </Stack>\n )\n}\nConversionAttribution.displayName = 'ConversionAttribution'\n\nexport default ConversionAttribution\n"],"names":["AppLink","Chip","Stack","Typography","doc","useFirestore","useFirestoreDoc","useMarketingHubPath","campaignConversionId","campaignTouchLabel","ConversionAttribution","props","record","hostId","kind","refId","quiet","firestore","resolvedHub","marketingBasePath","attributionId","data","status","spacing","variant","color","label","converted","Number","convertedAtMs","touched","touchedAtMs","isEmail","channel","isSequence","campaignHref","campaignId","undefined","direction","useFlexGap","sx","alignItems","flexWrap","size","href","fontWeight","Date","toLocaleString","model","String","windowDays","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,OAAO,QAAQ,uBAAsB;AAC9C,SAASC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACvD,SAASC,GAAG,QAAQ,qBAAoB;AACxC,SAASC,YAAY,EAAEC,eAAe,QAAQ,iCAAgC;AAC9E,SAASC,mBAAmB,QAAQ,2BAAuB;AAC3D,SACEC,oBAAoB,EACpBC,kBAAkB,QAGb,8DAA6D;AA6DpE,OAAO,SAASC,sBAAsBC,KAAiC;QAW3CA,0BA4DDC,uBACFA,qBA2DWA,eACPA;IAnI3B,MAAM,EAAEC,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAEC,KAAK,EAAE,GAAGL;IACvC,MAAMM,YAAYZ;IAClB;;;;;;GAMC,GACD,MAAMa,cAAcX;IACpB,MAAMY,qBAAoBR,2BAAAA,MAAMQ,iBAAiB,YAAvBR,2BAA2BO;IAErD;;;;GAIC,GACD,MAAME,gBAAgBZ,qBAAqBM,MAAMC;IACjD,MAAM,EAAEM,MAAMT,MAAM,EAAEU,MAAM,EAAE,GAAGhB,gBAC/B,IACEc,gBACIhB,IACEa,WACA,SACAJ,QACA,wBACAO,iBAEF,MACN;QAACH;QAAWJ;QAAQO;KAAc;IAGpC;;;;;GAKC,GACD,IAAI,CAACA,eAAe,OAAO;IAE3B;;;;;GAKC,GACD,IAAIE,WAAW,WAAW,OAAO;IAEjC,IAAI,CAACV,QAAQ;QACX,IAAII,OAAO,OAAO;QAClB,qBACE,MAACd;YAAMqB,SAAS;;8BACd,KAACpB;oBAAWqB,SAAQ;oBAAWC,OAAM;8BAClC;;8BAMH,KAACtB;oBAAWqB,SAAQ;oBAAQC,OAAM;8BAC/B,mEACC,+DACA,oEACA;;;;IAIV;IAEA,MAAMC,QAAQjB,mBAAmBG;IACjC,MAAMe,YAAYC,QAAOhB,wBAAAA,OAAOiB,aAAa,YAApBjB,wBAAwB;IACjD,MAAMkB,UAAUF,QAAOhB,sBAAAA,OAAOmB,WAAW,YAAlBnB,sBAAsB;IAC7C,MAAMoB,UAAUpB,OAAOqB,OAAO,KAAK;IACnC,MAAMC,aAAatB,OAAOqB,OAAO,KAAK;IACtC,MAAME,eACJ,AAACH,CAAAA,WAAWE,UAAS,KAAMf,qBAAqBP,OAAOwB,UAAU,GAC7D,GAAGjB,kBAAkB,WAAW,EAAEP,OAAOwB,UAAU,EAAE,GACrDC;IAEN,qBACE,MAACnC;QAAMqB,SAAS;;0BACd,KAACpB;gBAAWqB,SAAQ;gBAAWC,OAAM;0BAClC;;0BAEH,MAACvB;gBACCoC,WAAU;gBACVf,SAAS;gBACTgB,UAAU;gBACVC,IAAI;oBAAEC,YAAY;oBAAUC,UAAU;gBAAO;;kCAO7C,KAACzC;wBACC0C,MAAK;wBACLlB,OAAOO,WAAWE,aAAa,YAAY;wBAC3CV,SAASQ,WAAWE,aAAa,WAAW;wBAC5CR,OAAOM,UAAU,mBAAmBE,aAAa,aAAa;;oBAE/DC,6BACC,KAACnC;wBAAQ4C,MAAMT;kCAAeT,SAASd,OAAOwB,UAAU;uCAExD,KAACjC;wBAAWqB,SAAQ;wBAAQgB,IAAI;4BAAEK,YAAY;wBAAO;kCAClDnB,SAAS;;;;0BAWhB,KAACvB;gBAAWqB,SAAQ;gBAAUC,OAAM;0BACjC,AAACS,CAAAA,aACEJ,UACE,CAAC,+BAA+B,EAAE,IAAIgB,KAAKhB,SAASiB,cAAc,IAAI,GACtE,gCACFjB,UACE,CAAC,qBAAqB,EAAE,IAAIgB,KAAKhB,SAASiB,cAAc,IAAI,GAC5D,0BAAyB,IAC5BpB,CAAAA,YACG,CAAC,YAAY,EAAE,IAAImB,KAAKnB,WAAWoB,cAAc,IAAI,GACrD,EAAC,IACL,CAAC,WAAW,EACVnC,OAAOoC,KAAK,KAAK,eACb,iDACA,CAAC,UAAU,EAAEC,QAAOrC,gBAAAA,OAAOoC,KAAK,YAAZpC,gBAAgB,YAAY,MAAM,CAAC,CAC5D,SAAS,EAAEgB,QAAOhB,qBAAAA,OAAOsC,UAAU,YAAjBtC,qBAAqB,MAAM,EAAE,oBAAoB,CAAC;;;;AAI/E;AACAF,sBAAsByC,WAAW,GAAG;AAEpC,eAAezC,sBAAqB"}
|
|
@@ -88,6 +88,7 @@ import { CAMPAIGN_MEMBER_HOST_COLLECTIONS, CAMPAIGN_MEMBERSHIP_FIELD, contactCam
|
|
|
88
88
|
import { CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model";
|
|
89
89
|
import { SCREEN_KIND_EMAIL } from "@aglyn/aglyn/app-utils/screen-route";
|
|
90
90
|
import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
|
|
91
|
+
import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin-membership-detach";
|
|
91
92
|
/**
|
|
92
93
|
* Sends detached in one write.
|
|
93
94
|
*
|
|
@@ -194,7 +195,8 @@ import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/p
|
|
|
194
195
|
};
|
|
195
196
|
}
|
|
196
197
|
/**
|
|
197
|
-
* Clears the campaign off every form, screen and contact holding it
|
|
198
|
+
* Clears the campaign off every form, screen, lead and contact holding it,
|
|
199
|
+
* and then off every record a plugin keeps about it (AGL-3254).
|
|
198
200
|
*
|
|
199
201
|
* ## Why the campaign is not simply deleted over the top of them
|
|
200
202
|
*
|
|
@@ -219,6 +221,18 @@ import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/p
|
|
|
219
221
|
* A group that cannot be resolved answers as the site alone, which is
|
|
220
222
|
* {@link consentGroupForSite}'s documented failure direction and the safe one
|
|
221
223
|
* here too: the pass then clears the site's own facet and no other.
|
|
224
|
+
*
|
|
225
|
+
* ## A plugin's members are the plugin's to clear
|
|
226
|
+
*
|
|
227
|
+
* A sequence and its enrollments name the campaign from documents under the
|
|
228
|
+
* org, in collections this plugin does not know (AGL-3254). Every plugin
|
|
229
|
+
* with a membership detacher registered on the core's seam is asked, with
|
|
230
|
+
* the campaign's id and the field it is held in, after the site's own
|
|
231
|
+
* records, and a detacher that reports records
|
|
232
|
+
* remaining — or threw, which is reported as `null` — holds the container
|
|
233
|
+
* exactly as this pass's own `remaining` does. A plugin that failed is a
|
|
234
|
+
* plugin whose records may still name the campaign, and the deletion has
|
|
235
|
+
* no way to tell that apart from one that has more than a request clears.
|
|
222
236
|
*/ async function detachMembers(hostId, hostRef, campaignId) {
|
|
223
237
|
const firestore = hostRef.firestore;
|
|
224
238
|
let detached = 0;
|
|
@@ -239,6 +253,17 @@ import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/p
|
|
|
239
253
|
* naming it. A thrown read reaches the handler's 500 and the container
|
|
240
254
|
* survives, which is the direction a failure here has to fall.
|
|
241
255
|
*/ const orgId = await resolveOrgIdForHost(hostId);
|
|
256
|
+
const plugins = await runPluginMembershipDetachers({
|
|
257
|
+
hostId,
|
|
258
|
+
orgId: orgId != null ? orgId : '',
|
|
259
|
+
field: CAMPAIGN_MEMBERSHIP_FIELD,
|
|
260
|
+
id: campaignId
|
|
261
|
+
});
|
|
262
|
+
for (const report of Object.values(plugins)){
|
|
263
|
+
var _ref;
|
|
264
|
+
detached += (_ref = report == null ? void 0 : report.detached) != null ? _ref : 0;
|
|
265
|
+
remaining = remaining || report === null || report.remaining;
|
|
266
|
+
}
|
|
242
267
|
if (!orgId) return {
|
|
243
268
|
detached,
|
|
244
269
|
remaining
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-manage.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 * TAKING SOMETHING AWAY: a campaign container, and an abandoned draft.\n *\n * Both are removals, and neither may take a delivered message with it. That\n * is the whole reason this file is small and the comments are long.\n *\n * ## Why these two are not client writes\n *\n * The console creates and edits a campaign CONTAINER with the client SDK —\n * `hosts/{hostId}/emailCampaigns` is deliberately outside the rules'\n * server-only exclusion list, because a container holds no counter, no\n * consent record and no entitlement input.\n *\n * The SEND collection is the opposite: `hosts/{hostId}/campaigns` is excluded\n * from client create, update AND delete, because each document is the record\n * of what a merchant mailed and to whom they were allowed to mail it. Both\n * operations here have to touch it — one to detach sends from the container\n * being removed, the other to remove a draft outright — so both run on the\n * Admin SDK behind the same site-role check the send route uses.\n *\n * ## What deleting a campaign MEANS\n *\n * The container goes; every email inside it stays, and reads afterwards as a\n * single send.\n *\n * It cannot mean anything else. A send id is cited by mail already delivered\n * — every unsubscribe footer carries `cid={sendId}` inside its own HMAC, and\n * `/marketing/campaigns/{sendId}` is a URL merchants paste into their own\n * messages — so deleting a container that destroyed its sends would break\n * opt-out links that must go on resolving forever, which is a compliance\n * failure rather than a broken page.\n *\n * Nor may the sends simply be left pointing at a container that is gone.\n * `campaignListRows` groups sends by the container they name and draws one\n * row per container; a send naming a container that no longer exists is in\n * neither half of that — not a container row, and not an orphan the list\n * adopts — so it would vanish from the campaigns table while remaining\n * perfectly deliverable. Detaching is what puts it back in the list, as the\n * \"Single send\" the product already models and the table already draws.\n *\n * Deleting a campaign is therefore NOT a way to stop its mail. A scheduled\n * email inside it keeps its send time and still goes out, because a\n * container is a grouping and cancelling somebody's mail is `cancel`'s job.\n * The console says so before it asks.\n *\n * ## The same rule for everything else the campaign held\n *\n * A campaign also groups forms, screens and contacts, each of which names it\n * from its OWN document. They are detached on exactly the argument the sends\n * are: a form left naming a container nobody can read has not been freed, it\n * has been made unreadable — its page draws a dead id where a campaign name\n * belongs, and the campaigns table can no longer find it. The record itself\n * is never touched. Deleting a campaign deletes a campaign.\n */\n\n/*\n * The MODULE, not the barrel. `@aglyn/tenant-data-admin`'s index reaches\n * `render-cache`, which imports `next/cache` and therefore the whole Next\n * server pipeline — everything this file needs from the admin SDK is one\n * default export, and taking it from the leaf keeps that pipeline out of the\n * graph.\n */\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { logResourceDuplicated } from '@aglyn/tenant-data-admin/server/duplicate-activity'\nimport {\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n uniqueDuplicateName,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { claimAttempt, createResourceUid } from '@aglyn/aglyn/server'\n/*\n * The MODULES again, for the same reason: `consentGroupForSite` and\n * `orgDataCollectionForHost` are what the contact pass needs, and taking them\n * from `@aglyn/tenant-data-admin`'s index would pull the whole Next server\n * pipeline back into this file's graph.\n */\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n CAMPAIGN_MEMBER_HOST_COLLECTIONS,\n CAMPAIGN_MEMBERSHIP_FIELD,\n contactCampaignFieldPath,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { SCREEN_KIND_EMAIL } from '@aglyn/aglyn/app-utils/screen-route'\nimport {\n registerPluginResourceDraftWriter,\n type PluginDraftRecord,\n type PluginDraftRefusal,\n type PluginDraftWrite,\n type PluginResourceDraftWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\n\n/**\n * Sends detached in one write.\n *\n * Under Firestore's 500-operation batch limit with room to spare rather than\n * at it: a batch that is refused for being one over has done nothing, and the\n * margin costs one extra round trip on a campaign of exactly this size.\n */\nconst DETACH_BATCH = 400\n\n/**\n * How many batches one request will run.\n *\n * A ceiling on the REQUEST, not on the campaign. The loop below terminates on\n * its own — each pass clears the very field it queries on, so a detached send\n * is not returned again — but a request that runs unbounded is a request that\n * eventually exceeds the platform's own timeout with no idea how far it got.\n *\n * Stopping early is safe and is why this is a ceiling rather than a refusal\n * up front: detaching is idempotent and partial progress is a consistent\n * state — the sends already detached read as single sends, the rest still\n * read under the campaign — so the answer to hitting it is to ask again.\n */\nconst DETACH_PASSES = 25\n\n/** What one `campaigns/manage` call answered with. */\ninterface ManageResult {\n status: number\n body: Record<string, unknown>\n}\n\n/**\n * Clears {@link CAMPAIGN_SEND_CONTAINER_FIELD} from every send in a campaign.\n *\n * Equality on one field with a `limit` — no `orderBy` — which Firestore's\n * automatic single-field index serves. Ordering would need a composite index\n * for a walk whose order does not matter: every match is being written, so\n * which order they come back in cannot change the outcome.\n *\n * @returns how many were detached, and whether any were left.\n */\nasync function detachSends(\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n const firestore = hostRef.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await hostRef\n .collection('campaigns')\n .where(CAMPAIGN_SEND_CONTAINER_FIELD, '==', campaignId)\n .limit(DETACH_BATCH)\n .get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const send of page.docs) {\n /*\n * The FIELD is removed, not set to an empty string. `campaignListRows`\n * adopts a send whose container id is falsy, so an empty string would\n * work by accident today — but the field is also what the campaign\n * detail page's `where` clause matches, and an equality query on `''`\n * is a different query from the absence the pre-container sends have.\n * One shape for \"belongs to no campaign\" is what keeps those two\n * readers agreeing.\n */\n batch.update(send.ref, {\n [CAMPAIGN_SEND_CONTAINER_FIELD]:\n firebaseAdmin.firestore.FieldValue.delete(),\n })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/**\n * Clears one campaign id out of a membership array, wherever it is held.\n *\n * The same walk {@link detachSends} makes, over an ARRAY field rather than a\n * scalar one: `array-contains` matches on Firestore's automatic single-field\n * index, and `arrayRemove` takes out the one id without touching the other\n * campaigns a form or a screen is in. That is the whole reason the membership\n * is an array here and a `FieldValue.delete()` on a send — a send is in one\n * campaign and a landing page is in several, so removing one must not be\n * removing all of them.\n *\n * `query` rather than a collection ref, because the contact pass hands in a\n * path this file cannot build for itself.\n *\n * @returns how many were detached, and whether any were left.\n */\nasync function detachMembership(\n firestore: FirebaseFirestore.Firestore,\n collectionRef: FirebaseFirestore.CollectionReference,\n fieldPath: string,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collectionRef\n .where(fieldPath, 'array-contains', campaignId)\n .limit(DETACH_BATCH)\n .get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, {\n [fieldPath]: firebaseAdmin.firestore.FieldValue.arrayRemove(campaignId),\n })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/**\n * Clears the campaign off every form, screen and contact holding it.\n *\n * ## Why the campaign is not simply deleted over the top of them\n *\n * A campaign that went away leaving members naming it is the same failure the\n * send detach exists to prevent, one collection over: the campaign's own page\n * finds its forms and screens with `array-contains`, and a form still naming\n * a container nobody can read is in neither half of that — not a member of\n * any campaign the console can draw, and not free of one either. Its own page\n * would render the dead id as a chip with no name.\n *\n * ## The contacts are reached by a different path, on purpose\n *\n * A contact lives on the ORG (`orgs/{orgId}/contacts`) and is shared by every\n * site in it, so the membership sits inside this site's consent-group facet\n * rather than at the top of the document. The field PATH is therefore the\n * scope: only rows this group filed under the campaign match it, and no other\n * holder's filing is touched. The unscoped collection ref is what the walk\n * runs on precisely because the path already carries the boundary — the\n * `visibleTo` filter `orgDataQueryForHost` adds would spend the query's one\n * array-contains slot and leave none for the membership.\n *\n * A group that cannot be resolved answers as the site alone, which is\n * {@link consentGroupForSite}'s documented failure direction and the safe one\n * here too: the pass then clears the site's own facet and no other.\n */\nasync function detachMembers(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n const firestore = hostRef.firestore\n let detached = 0\n let remaining = false\n for (const collectionName of CAMPAIGN_MEMBER_HOST_COLLECTIONS) {\n const pass = await detachMembership(\n firestore,\n hostRef.collection(collectionName),\n CAMPAIGN_MEMBERSHIP_FIELD,\n campaignId,\n )\n detached += pass.detached\n remaining = remaining || pass.remaining\n }\n /*\n * NO ORG, NO ORG CONTACTS — and that is a skip rather than a failure.\n *\n * Contacts live at `orgs/{orgId}/contacts`, so a site whose `hostIndex`\n * entry names no org holds none of them and there is nothing here to\n * detach. Resolving it first is what separates that from a read that FAILED:\n * `orgDataCollectionForHost` throws for both, and swallowing both would let\n * a transient Firestore error delete the campaign with contacts still\n * naming it. A thrown read reaches the handler's 500 and the container\n * survives, which is the direction a failure here has to fall.\n */\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return { detached, remaining }\n const group = await consentGroupForSite(hostId)\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const contactPass = await detachMembership(\n firestore,\n contacts,\n contactCampaignFieldPath(group.groupId),\n campaignId,\n )\n return {\n detached: detached + contactPass.detached,\n remaining: remaining || contactPass.remaining,\n }\n}\n\n/**\n * Removes a campaign container, leaving its emails standing.\n *\n * Detach first, delete second, and the order is the point: stopping between\n * them leaves a container whose emails are already single sends, which the\n * list draws correctly and a second run finishes. Deleting first would leave\n * sends naming a container nobody can read, which is the state that loses\n * them from the table.\n */\nasync function deleteCampaign(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<ManageResult> {\n const ref = hostRef.collection('emailCampaigns').doc(campaignId)\n const snapshot = await ref.get()\n /*\n * A 404 rather than a silent success. The id in a campaign URL may name a\n * container OR a single send — that ambiguity is what keeps every link\n * minted before containers existed resolving — so \"no container here\" is\n * very often \"this is a send\", and answering it as a completed deletion\n * would tell the console it had removed something it never touched.\n */\n if (!snapshot.exists) {\n return { status: 404, body: { error: 'Unknown campaign' } }\n }\n const { detached, remaining } = await detachSends(hostRef, campaignId)\n if (remaining) {\n return {\n status: 409,\n body: {\n detached,\n error:\n `This campaign holds more emails than one request can detach. ` +\n `${detached.toLocaleString()} of them now read as single sends and ` +\n 'the campaign is still here — run the delete again to finish it.',\n },\n }\n }\n /*\n * The MEMBERS come off before the container does, for the reason the sends\n * do: a partial run leaves records that are already out of the campaign,\n * which every surface draws correctly, while deleting first would leave\n * forms, screens and contacts naming an id nothing can resolve.\n *\n * Their own 409 rather than a shared one. \"More emails than one request can\n * detach\" is a sentence about the campaign's mail, and a merchant reading\n * it about a campaign of two emails and four thousand contacts would go\n * looking for emails that are not the problem.\n */\n const members = await detachMembers(hostId, hostRef, campaignId)\n if (members.remaining) {\n return {\n status: 409,\n body: {\n detached,\n detachedMembers: members.detached,\n error:\n 'This campaign is on more records than one request can clear. ' +\n `${members.detached.toLocaleString()} of them are already out of ` +\n 'it and the campaign is still here — run the delete again to ' +\n 'finish it.',\n },\n }\n }\n await ref.delete()\n return {\n status: 200,\n body: {\n campaignId,\n detached,\n detachedMembers: members.detached,\n deleted: true,\n },\n }\n}\n\n/**\n * Discards a DRAFT email, and refuses anything else.\n *\n * The state check is inside the transaction as well as being the reason for\n * it. `sendNow` claims a draft by moving it to `sending` in a transaction of\n * its own, so a check made against an earlier read could delete a record\n * while the send path was mailing from it — the merchant would receive a\n * report for an email that no longer exists, and its `cid` would stop\n * resolving with the message already in inboxes.\n *\n * Only `draft`. A scheduled email is withdrawn with `cancel`, which leaves\n * the record and its report standing; a sent one has reached people, and\n * nothing on this surface may remove the evidence of that.\n */\nasync function discardDraft(\n hostRef: FirebaseFirestore.DocumentReference,\n emailId: string,\n): Promise<ManageResult> {\n const ref = hostRef.collection('campaigns').doc(emailId)\n const firestore = hostRef.firestore\n const refusal = (state: string): string => {\n if (state === 'sent') {\n return 'This email has already been sent, so it cannot be discarded. ' +\n 'Its report and its unsubscribe links have to go on resolving.'\n }\n if (state === 'scheduled') {\n return 'This email is scheduled. Cancel the send first — cancelling ' +\n 'keeps the email and takes it off the clock.'\n }\n if (state === 'sending') {\n return 'This email is being sent right now.'\n }\n return 'Only a draft can be discarded.'\n }\n const outcome = await firestore.runTransaction(\n async (transaction): Promise<ManageResult> => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) {\n return { status: 404, body: { error: 'Unknown email' } }\n }\n const state = String(fresh.get('status') ?? '')\n if (state !== 'draft') {\n return { status: 409, body: { error: refusal(state) } }\n }\n /*\n * A plain delete, with nothing recursive under it. The one\n * subcollection a message grows is `reports/links`, written by the\n * delivery webhook — an email that has never been sent has no\n * deliveries, so there is nothing beneath a draft to leave behind.\n */\n transaction.delete(ref)\n return { status: 200, body: { emailId, discarded: true } }\n },\n )\n return outcome\n}\n\n/**\n * Campaign management API: removing a campaign, and discarding a draft.\n *\n * Separate from `campaigns/send` because nothing here sends, reserves\n * allowance or moves a meter, and because both operations are about a record\n * ceasing to exist — which is the one class of change that has to be read\n * against what a merchant has already mailed. Same authorization as the send\n * route: a site admin or editor, proven by a Firebase ID token.\n *\n * Editing a campaign is deliberately NOT here. The container is client-\n * writable by the same roles, its create already runs on the client SDK, and\n * a second door to the same document would be a second place for the two to\n * disagree about what a campaign may hold.\n */\n/**\n * The fields a copy of a message carries (AGL-2936): what was written and\n * how it is sent — never who it goes to or when. The audience, list,\n * segment, addresses, schedule and experiment are the decisions a person\n * makes about THIS send, and a copy that inherited them would be one click\n * from mailing the same people twice.\n */\nconst DUPLICATED_MESSAGE_FIELDS = [\n 'subject',\n 'body',\n 'fromName',\n 'replyTo',\n 'senderId',\n 'templateScreenId',\n 'plainText',\n 'plainTextVersionId',\n 'topicId',\n CAMPAIGN_SEND_CONTAINER_FIELD,\n] as const\n\n/**\n * Copies a message as a new draft (AGL-2936). Any status can be copied —\n * a sent email is the usual source — and the copy is always `draft`, with\n * the name the person gave it made unique among the site's messages.\n */\nasync function duplicateMessage(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n emailId: string,\n requestedName: string,\n actor: { uid: string; email: string | null; orgId: string },\n): Promise<ManageResult> {\n const messages = hostRef.collection('campaigns')\n const firestore = hostRef.firestore\n const id = createResourceUid()\n const outcome = await firestore.runTransaction(\n async (transaction): Promise<ManageResult & { name?: string; sourceName?: string }> => {\n const [source, siblings] = await Promise.all([\n transaction.get(messages.doc(emailId)),\n transaction.get(messages.select('displayName', 'subject')),\n ])\n if (!source.exists) {\n return { status: 404, body: { error: 'Unknown email' } }\n }\n const data = (source.data() ?? {}) as Record<string, unknown>\n const sourceName = String(data['displayName'] ?? data['subject'] ?? '').trim()\n const name = uniqueDuplicateName(\n requestedName.trim() || duplicateDisplayName(sourceName),\n siblings.docs.map((row) => String(row.get('displayName') ?? row.get('subject') ?? '')),\n )\n const copy: Record<string, unknown> = {}\n for (const field of DUPLICATED_MESSAGE_FIELDS) {\n if (data[field] !== undefined && data[field] !== null) copy[field] = data[field]\n }\n const nowMs = Date.now()\n transaction.create(messages.doc(id), {\n ...copy,\n displayName: name,\n status: 'draft',\n createdAtMs: nowMs,\n draftedAt: new Date(nowMs),\n draftedBy: actor.uid,\n })\n return { status: 200, body: { emailId: id, name }, name, sourceName }\n },\n )\n if (outcome.status === 200) {\n await logResourceDuplicated(\n 'campaign',\n { uid: actor.uid, email: actor.email },\n {\n orgId: actor.orgId,\n hostId,\n source: { id: emailId, name: outcome.sourceName ?? '' },\n target: { id, name: outcome.name ?? '' },\n },\n )\n }\n return { status: outcome.status, body: outcome.body }\n}\n\n/*==========================================\n * A CAMPAIGN THAT EXISTS BEFORE ANYBODY CHOSE WHO GETS IT (AGL-2912).\n *\n * The draft writer this plugin registers for the `campaign` resource on the\n * core's resource-drafts seam. Another plugin that produces a campaign — a\n * generator working from a brief, an importer bringing campaigns over from\n * another tool — asks for it by name, and gets exactly two documents:\n *\n * - the CONTAINER at `emailCampaigns/{id}`, written as the campaigns list's\n * create drawer writes one, aimed at no list;\n * - ONE EMAIL inside it at `campaigns/{id}-email`, `status: 'draft'`, naming\n * the email design it sends and holding its subject, preheader and the\n * alternatives to them.\n *\n * ## What a drafted email never holds\n *\n * An audience, a list, a segment, addresses, a topic, a sender, a send time or\n * an experiment. Those are the decisions a person makes about a send — the\n * duplicate above strips the same ones for the same reason — and a draft that\n * arrived holding any of them would be one click from mailing people nobody\n * chose. So the writer has no field for them to arrive in.\n *\n * ## Why a draft cannot escape\n *\n * Nothing here reserves an allowance, claims a budget or calls the send path,\n * and the scheduled processor queries `status == 'scheduled'`, which a draft\n * never is. `performCampaignSend` is the only thing that mails a draft, behind\n * the send route's own authorization, when a member asks.\n *\n * The id of the email is derived from the campaign's rather than minted, so a\n * write asked again finds the email it made — and differs from it, because a\n * campaign URL names a container OR a send, and one id naming both would read\n * as either.\n *=========================================*/\n\n/** The resource name the campaign draft writer is registered under. */\nexport const CAMPAIGN_DRAFT_RESOURCE = 'campaign'\n\n/** The id `plugins.config.json` registers this plugin under. */\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** A drafted campaign's name when the caller asks for none. */\nexport const CAMPAIGN_DRAFT_DEFAULT_NAME = 'Untitled campaign'\n\n/** The longest subject or preheader a drafted email stores: the send route's header cap. */\nexport const CAMPAIGN_DRAFT_HEADER_MAX_CHARS = 200\n\n/** The most alternative subject lines or preheaders a drafted email keeps. */\nexport const CAMPAIGN_DRAFT_MAX_VARIANTS = 10\n\n/** The site roles that may compose this site's mail. */\nexport const CAMPAIGN_DRAFT_ROLES = ['admin', 'editor'] as const\n/** The send route's refusal for a member who may not compose this site's mail. */\nexport const CAMPAIGN_DRAFT_ROLE_REFUSAL = 'Not a site admin or editor'\n\n/** The id of the one email inside a drafted campaign. */\nexport function campaignDraftEmailId(campaignId: string): string {\n return `${campaignId}-email`\n}\n\n/** Every field a drafted email is written with, and so every field it can hold. */\nexport const CAMPAIGN_DRAFT_EMAIL_FIELDS = [\n 'subject',\n 'preheader',\n 'templateScreenId',\n 'subjectVariants',\n 'preheaderVariants',\n CAMPAIGN_SEND_CONTAINER_FIELD,\n 'status',\n 'createdAtMs',\n 'draftedAt',\n 'draftedBy',\n] as const\n\n/** What a caller sends as `content`. */\nexport interface CampaignDraftContent {\n /** The email design the email sends: a `kind: 'email'` screen on the site. */\n templateScreenId: string\n subject: string\n preheader: string\n subjectVariants: string[]\n preheaderVariants: string[]\n}\n\n/** A header value on one line: controls and runs of space folded to one space. */\nfunction draftHeaderLine(value: unknown): string {\n return String(value ?? '').replace(/[\\p{Cc}\\s]+/gu, ' ').trim()\n}\n\nfunction draftVariants(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const seen = new Set<string>()\n const lines: string[] = []\n for (const raw of value) {\n const line = draftHeaderLine(raw)\n if (!line || seen.has(line.toLowerCase())) continue\n seen.add(line.toLowerCase())\n lines.push(line)\n }\n return lines.slice(0, CAMPAIGN_DRAFT_MAX_VARIANTS)\n}\n\nexport function readCampaignDraftContent(\n content: Readonly<Record<string, unknown>>,\n): { ok: true; value: CampaignDraftContent } | { ok: false; problems: string[] } {\n const value: CampaignDraftContent = {\n templateScreenId: String(content['templateScreenId'] ?? ''),\n subject: draftHeaderLine(content['subject']),\n preheader: draftHeaderLine(content['preheader']),\n subjectVariants: draftVariants(content['subjectVariants']),\n preheaderVariants: draftVariants(content['preheaderVariants']),\n }\n const problems: string[] = []\n if (!isDocumentId(value.templateScreenId)) {\n problems.push('A drafted campaign needs the email design it sends')\n }\n if (!value.subject) problems.push('A drafted campaign needs a subject line')\n const long = (lines: string[]) =>\n lines.some((line) => line.length > CAMPAIGN_DRAFT_HEADER_MAX_CHARS)\n if (long([value.subject, ...value.subjectVariants])) {\n problems.push(`A subject line is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`)\n }\n if (long([value.preheader, ...value.preheaderVariants])) {\n problems.push(`A preheader is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`)\n }\n return problems.length ? { ok: false, problems } : { ok: true, value }\n}\n\nfunction campaignDraftRoleRefusal(\n host: FirebaseFirestore.DocumentSnapshot,\n uid: string,\n): PluginDraftRefusal | null {\n const role = (host.get('memberRoles') ?? {})[uid]\n return CAMPAIGN_DRAFT_ROLES.includes(role)\n ? null\n : { status: 403, error: CAMPAIGN_DRAFT_ROLE_REFUSAL }\n}\n\nfunction campaignDraftRecord(campaign: FirebaseFirestore.DocumentSnapshot): PluginDraftRecord {\n return {\n id: campaign.id,\n name: String(campaign.get('name') ?? ''),\n versionId: null,\n facts: { emailId: campaignDraftEmailId(campaign.id) },\n }\n}\n\nexport interface CampaignDraftWriterDeps {\n /** The Admin SDK handle; specs hand in a double. */\n firestore?: () => FirebaseFirestore.Firestore\n}\n\nexport function createCampaignDraftWriter(\n deps: CampaignDraftWriterDeps = {},\n): PluginResourceDraftWriter {\n const firestore =\n deps.firestore ??\n (() => firebaseAdmin.app().firestore() as unknown as FirebaseFirestore.Firestore)\n return {\n refusal: async ({ hostId, uid }) => {\n const host = await firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return { status: 404, error: 'Unknown site' }\n return campaignDraftRoleRefusal(host, uid)\n },\n\n check: (content) => {\n const read = readCampaignDraftContent(content)\n return read.ok === false\n ? read\n : { ok: true, facts: { fields: [...CAMPAIGN_DRAFT_EMAIL_FIELDS] } }\n },\n\n read: async ({ hostId, id }) => {\n if (!isDocumentId(id)) return null\n const campaign = await firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('emailCampaigns')\n .doc(id)\n .get()\n return campaign.exists ? campaignDraftRecord(campaign) : null\n },\n\n write: async (request): Promise<PluginDraftWrite> => {\n const read = readCampaignDraftContent(request.content)\n if (read.ok === false) return { ok: false, status: 400, error: read.problems[0] }\n if (!isDocumentId(request.id)) return { ok: false, status: 400, error: 'Invalid campaignId' }\n const db = firestore()\n const hostRef = db.collection('hosts').doc(request.hostId)\n const campaignRef = hostRef.collection('emailCampaigns').doc(request.id)\n const emailId = campaignDraftEmailId(request.id)\n const emailRef = hostRef.collection('campaigns').doc(emailId)\n const designRef = hostRef.collection('screens').doc(read.value.templateScreenId)\n return db.runTransaction(async (transaction): Promise<PluginDraftWrite> => {\n const [host, campaign, email, design] = await Promise.all([\n transaction.get(hostRef),\n transaction.get(campaignRef),\n transaction.get(emailRef),\n transaction.get(designRef),\n ])\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (campaign.exists) {\n return { ok: true, replayed: true, ...campaignDraftRecord(campaign) }\n }\n const refusal = campaignDraftRoleRefusal(host, request.uid)\n if (refusal) return { ok: false, ...refusal }\n if (\n !design.exists ||\n design.get('deletedAt') != null ||\n design.get('kind') !== SCREEN_KIND_EMAIL\n ) {\n return { ok: false, status: 400, error: 'Unknown email design' }\n }\n if (email.exists) return { ok: false, status: 409, error: 'That email already exists' }\n const name = draftHeaderLine(request.name) || CAMPAIGN_DRAFT_DEFAULT_NAME\n const nowMs = request.now.getTime()\n // The create drawer's own document: a name, no dates, no lists.\n transaction.create(campaignRef, {\n name,\n listIds: [],\n createdAtMs: nowMs,\n createdBy: request.uid,\n })\n const { value } = read\n transaction.create(emailRef, {\n subject: value.subject,\n ...(value.preheader ? { preheader: value.preheader } : {}),\n templateScreenId: value.templateScreenId,\n ...(value.subjectVariants.length ? { subjectVariants: value.subjectVariants } : {}),\n ...(value.preheaderVariants.length ? { preheaderVariants: value.preheaderVariants } : {}),\n [CAMPAIGN_SEND_CONTAINER_FIELD]: request.id,\n status: 'draft',\n createdAtMs: nowMs,\n draftedAt: request.now,\n draftedBy: request.uid,\n })\n return {\n ok: true,\n replayed: false,\n id: request.id,\n name,\n versionId: null,\n facts: { emailId },\n }\n })\n },\n }\n}\n\nexport const campaignDraftWriter = createCampaignDraftWriter()\n\n/**\n * Registers the writer; the console surface calls it, since only the console\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerCampaignDraftWriter(): void {\n registerPluginResourceDraftWriter(CAMPAIGN_DRAFT_RESOURCE, campaignDraftWriter, {\n pluginId: MARKETING_PLUGIN_ID,\n })\n}\n\nexport const campaignManageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const action = String(req.body?.action ?? '')\n const targetId = String(req.body?.campaignId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n if (!isDocumentId(targetId)) {\n return res.status(400).json({ error: 'Invalid campaignId' })\n }\n if (\n action !== 'deleteCampaign' &&\n action !== 'discardEmail' &&\n action !== 'duplicate'\n ) {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n if (action === 'duplicate') {\n // One attempt key, one copy (AGL-2936): a double click on Duplicate\n // replays the first answer rather than drafting the message twice.\n const attemptKey = String(req.body?.attemptKey ?? '').trim().slice(0, 200)\n const orgId = (await resolveOrgIdForHost(hostId)) ?? ''\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: 'duplicate-campaign',\n scopeId: `${hostId}:${targetId}`,\n orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n const copied = await duplicateMessage(\n hostId,\n hostRef,\n targetId,\n String(req.body?.name ?? ''),\n { uid: decoded.uid, email: decoded.email ?? null, orgId },\n )\n if (copied.status === 200) await claim?.record(copied.status, copied.body)\n else await claim?.release()\n return res.status(copied.status).json(copied.body)\n }\n const result =\n action === 'deleteCampaign'\n ? await deleteCampaign(hostId, hostRef, targetId)\n : await discardDraft(hostRef, targetId)\n return res.status(result.status).json(result.body)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'The request could not be completed' })\n }\n}\n"],"names":["firebaseAdmin","isDocumentId","logResourceDuplicated","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","uniqueDuplicateName","claimAttempt","createResourceUid","consentGroupForSite","orgDataCollectionForHost","resolveOrgIdForHost","CAMPAIGN_MEMBER_HOST_COLLECTIONS","CAMPAIGN_MEMBERSHIP_FIELD","contactCampaignFieldPath","CAMPAIGN_SEND_CONTAINER_FIELD","SCREEN_KIND_EMAIL","registerPluginResourceDraftWriter","DETACH_BATCH","DETACH_PASSES","detachSends","hostRef","campaignId","firestore","detached","pass","page","collection","where","limit","get","empty","remaining","batch","send","docs","update","ref","FieldValue","delete","commit","size","detachMembership","collectionRef","fieldPath","member","arrayRemove","detachMembers","hostId","collectionName","orgId","group","contacts","contactPass","groupId","deleteCampaign","doc","snapshot","exists","status","body","error","toLocaleString","members","detachedMembers","deleted","discardDraft","emailId","refusal","state","outcome","runTransaction","transaction","fresh","String","discarded","DUPLICATED_MESSAGE_FIELDS","duplicateMessage","requestedName","actor","messages","id","source","data","siblings","Promise","all","select","sourceName","trim","name","map","row","copy","field","undefined","nowMs","Date","now","create","displayName","createdAtMs","draftedAt","draftedBy","uid","email","target","CAMPAIGN_DRAFT_RESOURCE","MARKETING_PLUGIN_ID","CAMPAIGN_DRAFT_DEFAULT_NAME","CAMPAIGN_DRAFT_HEADER_MAX_CHARS","CAMPAIGN_DRAFT_MAX_VARIANTS","CAMPAIGN_DRAFT_ROLES","CAMPAIGN_DRAFT_ROLE_REFUSAL","campaignDraftEmailId","CAMPAIGN_DRAFT_EMAIL_FIELDS","draftHeaderLine","value","replace","draftVariants","Array","isArray","seen","Set","lines","raw","line","has","toLowerCase","add","push","slice","readCampaignDraftContent","content","templateScreenId","subject","preheader","subjectVariants","preheaderVariants","problems","long","some","length","ok","campaignDraftRoleRefusal","host","role","includes","campaignDraftRecord","campaign","versionId","facts","createCampaignDraftWriter","deps","app","check","read","fields","write","request","db","campaignRef","emailRef","designRef","design","replayed","getTime","listIds","createdBy","campaignDraftWriter","registerCampaignDraftWriter","pluginId","campaignManageHandler","req","res","method","json","action","targetId","authorization","headers","idToken","startsWith","hostSnapshot","decoded","auth","verifyIdToken","memberRole","attemptKey","claimed","kind","scopeId","key","busyMessage","replay","claim","copied","record","release","result","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-manage.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 * TAKING SOMETHING AWAY: a campaign container, and an abandoned draft.\n *\n * Both are removals, and neither may take a delivered message with it. That\n * is the whole reason this file is small and the comments are long.\n *\n * ## Why these two are not client writes\n *\n * The console creates and edits a campaign CONTAINER with the client SDK —\n * `hosts/{hostId}/emailCampaigns` is deliberately outside the rules'\n * server-only exclusion list, because a container holds no counter, no\n * consent record and no entitlement input.\n *\n * The SEND collection is the opposite: `hosts/{hostId}/campaigns` is excluded\n * from client create, update AND delete, because each document is the record\n * of what a merchant mailed and to whom they were allowed to mail it. Both\n * operations here have to touch it — one to detach sends from the container\n * being removed, the other to remove a draft outright — so both run on the\n * Admin SDK behind the same site-role check the send route uses.\n *\n * ## What deleting a campaign MEANS\n *\n * The container goes; every email inside it stays, and reads afterwards as a\n * single send.\n *\n * It cannot mean anything else. A send id is cited by mail already delivered\n * — every unsubscribe footer carries `cid={sendId}` inside its own HMAC, and\n * `/marketing/campaigns/{sendId}` is a URL merchants paste into their own\n * messages — so deleting a container that destroyed its sends would break\n * opt-out links that must go on resolving forever, which is a compliance\n * failure rather than a broken page.\n *\n * Nor may the sends simply be left pointing at a container that is gone.\n * `campaignListRows` groups sends by the container they name and draws one\n * row per container; a send naming a container that no longer exists is in\n * neither half of that — not a container row, and not an orphan the list\n * adopts — so it would vanish from the campaigns table while remaining\n * perfectly deliverable. Detaching is what puts it back in the list, as the\n * \"Single send\" the product already models and the table already draws.\n *\n * Deleting a campaign is therefore NOT a way to stop its mail. A scheduled\n * email inside it keeps its send time and still goes out, because a\n * container is a grouping and cancelling somebody's mail is `cancel`'s job.\n * The console says so before it asks.\n *\n * ## The same rule for everything else the campaign held\n *\n * A campaign also groups forms, screens and contacts, each of which names it\n * from its OWN document. They are detached on exactly the argument the sends\n * are: a form left naming a container nobody can read has not been freed, it\n * has been made unreadable — its page draws a dead id where a campaign name\n * belongs, and the campaigns table can no longer find it. The record itself\n * is never touched. Deleting a campaign deletes a campaign.\n */\n\n/*\n * The MODULE, not the barrel. `@aglyn/tenant-data-admin`'s index reaches\n * `render-cache`, which imports `next/cache` and therefore the whole Next\n * server pipeline — everything this file needs from the admin SDK is one\n * default export, and taking it from the leaf keeps that pipeline out of the\n * graph.\n */\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { logResourceDuplicated } from '@aglyn/tenant-data-admin/server/duplicate-activity'\nimport {\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n uniqueDuplicateName,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { claimAttempt, createResourceUid } from '@aglyn/aglyn/server'\n/*\n * The MODULES again, for the same reason: `consentGroupForSite` and\n * `orgDataCollectionForHost` are what the contact pass needs, and taking them\n * from `@aglyn/tenant-data-admin`'s index would pull the whole Next server\n * pipeline back into this file's graph.\n */\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n CAMPAIGN_MEMBER_HOST_COLLECTIONS,\n CAMPAIGN_MEMBERSHIP_FIELD,\n contactCampaignFieldPath,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { SCREEN_KIND_EMAIL } from '@aglyn/aglyn/app-utils/screen-route'\nimport {\n registerPluginResourceDraftWriter,\n type PluginDraftRecord,\n type PluginDraftRefusal,\n type PluginDraftWrite,\n type PluginResourceDraftWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport { runPluginMembershipDetachers } from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\n\n/**\n * Sends detached in one write.\n *\n * Under Firestore's 500-operation batch limit with room to spare rather than\n * at it: a batch that is refused for being one over has done nothing, and the\n * margin costs one extra round trip on a campaign of exactly this size.\n */\nconst DETACH_BATCH = 400\n\n/**\n * How many batches one request will run.\n *\n * A ceiling on the REQUEST, not on the campaign. The loop below terminates on\n * its own — each pass clears the very field it queries on, so a detached send\n * is not returned again — but a request that runs unbounded is a request that\n * eventually exceeds the platform's own timeout with no idea how far it got.\n *\n * Stopping early is safe and is why this is a ceiling rather than a refusal\n * up front: detaching is idempotent and partial progress is a consistent\n * state — the sends already detached read as single sends, the rest still\n * read under the campaign — so the answer to hitting it is to ask again.\n */\nconst DETACH_PASSES = 25\n\n/** What one `campaigns/manage` call answered with. */\ninterface ManageResult {\n status: number\n body: Record<string, unknown>\n}\n\n/**\n * Clears {@link CAMPAIGN_SEND_CONTAINER_FIELD} from every send in a campaign.\n *\n * Equality on one field with a `limit` — no `orderBy` — which Firestore's\n * automatic single-field index serves. Ordering would need a composite index\n * for a walk whose order does not matter: every match is being written, so\n * which order they come back in cannot change the outcome.\n *\n * @returns how many were detached, and whether any were left.\n */\nasync function detachSends(\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n const firestore = hostRef.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await hostRef\n .collection('campaigns')\n .where(CAMPAIGN_SEND_CONTAINER_FIELD, '==', campaignId)\n .limit(DETACH_BATCH)\n .get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const send of page.docs) {\n /*\n * The FIELD is removed, not set to an empty string. `campaignListRows`\n * adopts a send whose container id is falsy, so an empty string would\n * work by accident today — but the field is also what the campaign\n * detail page's `where` clause matches, and an equality query on `''`\n * is a different query from the absence the pre-container sends have.\n * One shape for \"belongs to no campaign\" is what keeps those two\n * readers agreeing.\n */\n batch.update(send.ref, {\n [CAMPAIGN_SEND_CONTAINER_FIELD]:\n firebaseAdmin.firestore.FieldValue.delete(),\n })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/**\n * Clears one campaign id out of a membership array, wherever it is held.\n *\n * The same walk {@link detachSends} makes, over an ARRAY field rather than a\n * scalar one: `array-contains` matches on Firestore's automatic single-field\n * index, and `arrayRemove` takes out the one id without touching the other\n * campaigns a form or a screen is in. That is the whole reason the membership\n * is an array here and a `FieldValue.delete()` on a send — a send is in one\n * campaign and a landing page is in several, so removing one must not be\n * removing all of them.\n *\n * `query` rather than a collection ref, because the contact pass hands in a\n * path this file cannot build for itself.\n *\n * @returns how many were detached, and whether any were left.\n */\nasync function detachMembership(\n firestore: FirebaseFirestore.Firestore,\n collectionRef: FirebaseFirestore.CollectionReference,\n fieldPath: string,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collectionRef\n .where(fieldPath, 'array-contains', campaignId)\n .limit(DETACH_BATCH)\n .get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, {\n [fieldPath]: firebaseAdmin.firestore.FieldValue.arrayRemove(campaignId),\n })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/**\n * Clears the campaign off every form, screen, lead and contact holding it,\n * and then off every record a plugin keeps about it (AGL-3254).\n *\n * ## Why the campaign is not simply deleted over the top of them\n *\n * A campaign that went away leaving members naming it is the same failure the\n * send detach exists to prevent, one collection over: the campaign's own page\n * finds its forms and screens with `array-contains`, and a form still naming\n * a container nobody can read is in neither half of that — not a member of\n * any campaign the console can draw, and not free of one either. Its own page\n * would render the dead id as a chip with no name.\n *\n * ## The contacts are reached by a different path, on purpose\n *\n * A contact lives on the ORG (`orgs/{orgId}/contacts`) and is shared by every\n * site in it, so the membership sits inside this site's consent-group facet\n * rather than at the top of the document. The field PATH is therefore the\n * scope: only rows this group filed under the campaign match it, and no other\n * holder's filing is touched. The unscoped collection ref is what the walk\n * runs on precisely because the path already carries the boundary — the\n * `visibleTo` filter `orgDataQueryForHost` adds would spend the query's one\n * array-contains slot and leave none for the membership.\n *\n * A group that cannot be resolved answers as the site alone, which is\n * {@link consentGroupForSite}'s documented failure direction and the safe one\n * here too: the pass then clears the site's own facet and no other.\n *\n * ## A plugin's members are the plugin's to clear\n *\n * A sequence and its enrollments name the campaign from documents under the\n * org, in collections this plugin does not know (AGL-3254). Every plugin\n * with a membership detacher registered on the core's seam is asked, with\n * the campaign's id and the field it is held in, after the site's own\n * records, and a detacher that reports records\n * remaining — or threw, which is reported as `null` — holds the container\n * exactly as this pass's own `remaining` does. A plugin that failed is a\n * plugin whose records may still name the campaign, and the deletion has\n * no way to tell that apart from one that has more than a request clears.\n */\nasync function detachMembers(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n const firestore = hostRef.firestore\n let detached = 0\n let remaining = false\n for (const collectionName of CAMPAIGN_MEMBER_HOST_COLLECTIONS) {\n const pass = await detachMembership(\n firestore,\n hostRef.collection(collectionName),\n CAMPAIGN_MEMBERSHIP_FIELD,\n campaignId,\n )\n detached += pass.detached\n remaining = remaining || pass.remaining\n }\n /*\n * NO ORG, NO ORG CONTACTS — and that is a skip rather than a failure.\n *\n * Contacts live at `orgs/{orgId}/contacts`, so a site whose `hostIndex`\n * entry names no org holds none of them and there is nothing here to\n * detach. Resolving it first is what separates that from a read that FAILED:\n * `orgDataCollectionForHost` throws for both, and swallowing both would let\n * a transient Firestore error delete the campaign with contacts still\n * naming it. A thrown read reaches the handler's 500 and the container\n * survives, which is the direction a failure here has to fall.\n */\n const orgId = await resolveOrgIdForHost(hostId)\n const plugins = await runPluginMembershipDetachers({\n hostId,\n orgId: orgId ?? '',\n field: CAMPAIGN_MEMBERSHIP_FIELD,\n id: campaignId,\n })\n for (const report of Object.values(plugins)) {\n detached += report?.detached ?? 0\n remaining = remaining || report === null || report.remaining\n }\n if (!orgId) return { detached, remaining }\n const group = await consentGroupForSite(hostId)\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const contactPass = await detachMembership(\n firestore,\n contacts,\n contactCampaignFieldPath(group.groupId),\n campaignId,\n )\n return {\n detached: detached + contactPass.detached,\n remaining: remaining || contactPass.remaining,\n }\n}\n\n/**\n * Removes a campaign container, leaving its emails standing.\n *\n * Detach first, delete second, and the order is the point: stopping between\n * them leaves a container whose emails are already single sends, which the\n * list draws correctly and a second run finishes. Deleting first would leave\n * sends naming a container nobody can read, which is the state that loses\n * them from the table.\n */\nasync function deleteCampaign(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<ManageResult> {\n const ref = hostRef.collection('emailCampaigns').doc(campaignId)\n const snapshot = await ref.get()\n /*\n * A 404 rather than a silent success. The id in a campaign URL may name a\n * container OR a single send — that ambiguity is what keeps every link\n * minted before containers existed resolving — so \"no container here\" is\n * very often \"this is a send\", and answering it as a completed deletion\n * would tell the console it had removed something it never touched.\n */\n if (!snapshot.exists) {\n return { status: 404, body: { error: 'Unknown campaign' } }\n }\n const { detached, remaining } = await detachSends(hostRef, campaignId)\n if (remaining) {\n return {\n status: 409,\n body: {\n detached,\n error:\n `This campaign holds more emails than one request can detach. ` +\n `${detached.toLocaleString()} of them now read as single sends and ` +\n 'the campaign is still here — run the delete again to finish it.',\n },\n }\n }\n /*\n * The MEMBERS come off before the container does, for the reason the sends\n * do: a partial run leaves records that are already out of the campaign,\n * which every surface draws correctly, while deleting first would leave\n * forms, screens and contacts naming an id nothing can resolve.\n *\n * Their own 409 rather than a shared one. \"More emails than one request can\n * detach\" is a sentence about the campaign's mail, and a merchant reading\n * it about a campaign of two emails and four thousand contacts would go\n * looking for emails that are not the problem.\n */\n const members = await detachMembers(hostId, hostRef, campaignId)\n if (members.remaining) {\n return {\n status: 409,\n body: {\n detached,\n detachedMembers: members.detached,\n error:\n 'This campaign is on more records than one request can clear. ' +\n `${members.detached.toLocaleString()} of them are already out of ` +\n 'it and the campaign is still here — run the delete again to ' +\n 'finish it.',\n },\n }\n }\n await ref.delete()\n return {\n status: 200,\n body: {\n campaignId,\n detached,\n detachedMembers: members.detached,\n deleted: true,\n },\n }\n}\n\n/**\n * Discards a DRAFT email, and refuses anything else.\n *\n * The state check is inside the transaction as well as being the reason for\n * it. `sendNow` claims a draft by moving it to `sending` in a transaction of\n * its own, so a check made against an earlier read could delete a record\n * while the send path was mailing from it — the merchant would receive a\n * report for an email that no longer exists, and its `cid` would stop\n * resolving with the message already in inboxes.\n *\n * Only `draft`. A scheduled email is withdrawn with `cancel`, which leaves\n * the record and its report standing; a sent one has reached people, and\n * nothing on this surface may remove the evidence of that.\n */\nasync function discardDraft(\n hostRef: FirebaseFirestore.DocumentReference,\n emailId: string,\n): Promise<ManageResult> {\n const ref = hostRef.collection('campaigns').doc(emailId)\n const firestore = hostRef.firestore\n const refusal = (state: string): string => {\n if (state === 'sent') {\n return 'This email has already been sent, so it cannot be discarded. ' +\n 'Its report and its unsubscribe links have to go on resolving.'\n }\n if (state === 'scheduled') {\n return 'This email is scheduled. Cancel the send first — cancelling ' +\n 'keeps the email and takes it off the clock.'\n }\n if (state === 'sending') {\n return 'This email is being sent right now.'\n }\n return 'Only a draft can be discarded.'\n }\n const outcome = await firestore.runTransaction(\n async (transaction): Promise<ManageResult> => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) {\n return { status: 404, body: { error: 'Unknown email' } }\n }\n const state = String(fresh.get('status') ?? '')\n if (state !== 'draft') {\n return { status: 409, body: { error: refusal(state) } }\n }\n /*\n * A plain delete, with nothing recursive under it. The one\n * subcollection a message grows is `reports/links`, written by the\n * delivery webhook — an email that has never been sent has no\n * deliveries, so there is nothing beneath a draft to leave behind.\n */\n transaction.delete(ref)\n return { status: 200, body: { emailId, discarded: true } }\n },\n )\n return outcome\n}\n\n/**\n * Campaign management API: removing a campaign, and discarding a draft.\n *\n * Separate from `campaigns/send` because nothing here sends, reserves\n * allowance or moves a meter, and because both operations are about a record\n * ceasing to exist — which is the one class of change that has to be read\n * against what a merchant has already mailed. Same authorization as the send\n * route: a site admin or editor, proven by a Firebase ID token.\n *\n * Editing a campaign is deliberately NOT here. The container is client-\n * writable by the same roles, its create already runs on the client SDK, and\n * a second door to the same document would be a second place for the two to\n * disagree about what a campaign may hold.\n */\n/**\n * The fields a copy of a message carries (AGL-2936): what was written and\n * how it is sent — never who it goes to or when. The audience, list,\n * segment, addresses, schedule and experiment are the decisions a person\n * makes about THIS send, and a copy that inherited them would be one click\n * from mailing the same people twice.\n */\nconst DUPLICATED_MESSAGE_FIELDS = [\n 'subject',\n 'body',\n 'fromName',\n 'replyTo',\n 'senderId',\n 'templateScreenId',\n 'plainText',\n 'plainTextVersionId',\n 'topicId',\n CAMPAIGN_SEND_CONTAINER_FIELD,\n] as const\n\n/**\n * Copies a message as a new draft (AGL-2936). Any status can be copied —\n * a sent email is the usual source — and the copy is always `draft`, with\n * the name the person gave it made unique among the site's messages.\n */\nasync function duplicateMessage(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n emailId: string,\n requestedName: string,\n actor: { uid: string; email: string | null; orgId: string },\n): Promise<ManageResult> {\n const messages = hostRef.collection('campaigns')\n const firestore = hostRef.firestore\n const id = createResourceUid()\n const outcome = await firestore.runTransaction(\n async (transaction): Promise<ManageResult & { name?: string; sourceName?: string }> => {\n const [source, siblings] = await Promise.all([\n transaction.get(messages.doc(emailId)),\n transaction.get(messages.select('displayName', 'subject')),\n ])\n if (!source.exists) {\n return { status: 404, body: { error: 'Unknown email' } }\n }\n const data = (source.data() ?? {}) as Record<string, unknown>\n const sourceName = String(data['displayName'] ?? data['subject'] ?? '').trim()\n const name = uniqueDuplicateName(\n requestedName.trim() || duplicateDisplayName(sourceName),\n siblings.docs.map((row) => String(row.get('displayName') ?? row.get('subject') ?? '')),\n )\n const copy: Record<string, unknown> = {}\n for (const field of DUPLICATED_MESSAGE_FIELDS) {\n if (data[field] !== undefined && data[field] !== null) copy[field] = data[field]\n }\n const nowMs = Date.now()\n transaction.create(messages.doc(id), {\n ...copy,\n displayName: name,\n status: 'draft',\n createdAtMs: nowMs,\n draftedAt: new Date(nowMs),\n draftedBy: actor.uid,\n })\n return { status: 200, body: { emailId: id, name }, name, sourceName }\n },\n )\n if (outcome.status === 200) {\n await logResourceDuplicated(\n 'campaign',\n { uid: actor.uid, email: actor.email },\n {\n orgId: actor.orgId,\n hostId,\n source: { id: emailId, name: outcome.sourceName ?? '' },\n target: { id, name: outcome.name ?? '' },\n },\n )\n }\n return { status: outcome.status, body: outcome.body }\n}\n\n/*==========================================\n * A CAMPAIGN THAT EXISTS BEFORE ANYBODY CHOSE WHO GETS IT (AGL-2912).\n *\n * The draft writer this plugin registers for the `campaign` resource on the\n * core's resource-drafts seam. Another plugin that produces a campaign — a\n * generator working from a brief, an importer bringing campaigns over from\n * another tool — asks for it by name, and gets exactly two documents:\n *\n * - the CONTAINER at `emailCampaigns/{id}`, written as the campaigns list's\n * create drawer writes one, aimed at no list;\n * - ONE EMAIL inside it at `campaigns/{id}-email`, `status: 'draft'`, naming\n * the email design it sends and holding its subject, preheader and the\n * alternatives to them.\n *\n * ## What a drafted email never holds\n *\n * An audience, a list, a segment, addresses, a topic, a sender, a send time or\n * an experiment. Those are the decisions a person makes about a send — the\n * duplicate above strips the same ones for the same reason — and a draft that\n * arrived holding any of them would be one click from mailing people nobody\n * chose. So the writer has no field for them to arrive in.\n *\n * ## Why a draft cannot escape\n *\n * Nothing here reserves an allowance, claims a budget or calls the send path,\n * and the scheduled processor queries `status == 'scheduled'`, which a draft\n * never is. `performCampaignSend` is the only thing that mails a draft, behind\n * the send route's own authorization, when a member asks.\n *\n * The id of the email is derived from the campaign's rather than minted, so a\n * write asked again finds the email it made — and differs from it, because a\n * campaign URL names a container OR a send, and one id naming both would read\n * as either.\n *=========================================*/\n\n/** The resource name the campaign draft writer is registered under. */\nexport const CAMPAIGN_DRAFT_RESOURCE = 'campaign'\n\n/** The id `plugins.config.json` registers this plugin under. */\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** A drafted campaign's name when the caller asks for none. */\nexport const CAMPAIGN_DRAFT_DEFAULT_NAME = 'Untitled campaign'\n\n/** The longest subject or preheader a drafted email stores: the send route's header cap. */\nexport const CAMPAIGN_DRAFT_HEADER_MAX_CHARS = 200\n\n/** The most alternative subject lines or preheaders a drafted email keeps. */\nexport const CAMPAIGN_DRAFT_MAX_VARIANTS = 10\n\n/** The site roles that may compose this site's mail. */\nexport const CAMPAIGN_DRAFT_ROLES = ['admin', 'editor'] as const\n/** The send route's refusal for a member who may not compose this site's mail. */\nexport const CAMPAIGN_DRAFT_ROLE_REFUSAL = 'Not a site admin or editor'\n\n/** The id of the one email inside a drafted campaign. */\nexport function campaignDraftEmailId(campaignId: string): string {\n return `${campaignId}-email`\n}\n\n/** Every field a drafted email is written with, and so every field it can hold. */\nexport const CAMPAIGN_DRAFT_EMAIL_FIELDS = [\n 'subject',\n 'preheader',\n 'templateScreenId',\n 'subjectVariants',\n 'preheaderVariants',\n CAMPAIGN_SEND_CONTAINER_FIELD,\n 'status',\n 'createdAtMs',\n 'draftedAt',\n 'draftedBy',\n] as const\n\n/** What a caller sends as `content`. */\nexport interface CampaignDraftContent {\n /** The email design the email sends: a `kind: 'email'` screen on the site. */\n templateScreenId: string\n subject: string\n preheader: string\n subjectVariants: string[]\n preheaderVariants: string[]\n}\n\n/** A header value on one line: controls and runs of space folded to one space. */\nfunction draftHeaderLine(value: unknown): string {\n return String(value ?? '').replace(/[\\p{Cc}\\s]+/gu, ' ').trim()\n}\n\nfunction draftVariants(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const seen = new Set<string>()\n const lines: string[] = []\n for (const raw of value) {\n const line = draftHeaderLine(raw)\n if (!line || seen.has(line.toLowerCase())) continue\n seen.add(line.toLowerCase())\n lines.push(line)\n }\n return lines.slice(0, CAMPAIGN_DRAFT_MAX_VARIANTS)\n}\n\nexport function readCampaignDraftContent(\n content: Readonly<Record<string, unknown>>,\n): { ok: true; value: CampaignDraftContent } | { ok: false; problems: string[] } {\n const value: CampaignDraftContent = {\n templateScreenId: String(content['templateScreenId'] ?? ''),\n subject: draftHeaderLine(content['subject']),\n preheader: draftHeaderLine(content['preheader']),\n subjectVariants: draftVariants(content['subjectVariants']),\n preheaderVariants: draftVariants(content['preheaderVariants']),\n }\n const problems: string[] = []\n if (!isDocumentId(value.templateScreenId)) {\n problems.push('A drafted campaign needs the email design it sends')\n }\n if (!value.subject) problems.push('A drafted campaign needs a subject line')\n const long = (lines: string[]) =>\n lines.some((line) => line.length > CAMPAIGN_DRAFT_HEADER_MAX_CHARS)\n if (long([value.subject, ...value.subjectVariants])) {\n problems.push(`A subject line is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`)\n }\n if (long([value.preheader, ...value.preheaderVariants])) {\n problems.push(`A preheader is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`)\n }\n return problems.length ? { ok: false, problems } : { ok: true, value }\n}\n\nfunction campaignDraftRoleRefusal(\n host: FirebaseFirestore.DocumentSnapshot,\n uid: string,\n): PluginDraftRefusal | null {\n const role = (host.get('memberRoles') ?? {})[uid]\n return CAMPAIGN_DRAFT_ROLES.includes(role)\n ? null\n : { status: 403, error: CAMPAIGN_DRAFT_ROLE_REFUSAL }\n}\n\nfunction campaignDraftRecord(campaign: FirebaseFirestore.DocumentSnapshot): PluginDraftRecord {\n return {\n id: campaign.id,\n name: String(campaign.get('name') ?? ''),\n versionId: null,\n facts: { emailId: campaignDraftEmailId(campaign.id) },\n }\n}\n\nexport interface CampaignDraftWriterDeps {\n /** The Admin SDK handle; specs hand in a double. */\n firestore?: () => FirebaseFirestore.Firestore\n}\n\nexport function createCampaignDraftWriter(\n deps: CampaignDraftWriterDeps = {},\n): PluginResourceDraftWriter {\n const firestore =\n deps.firestore ??\n (() => firebaseAdmin.app().firestore() as unknown as FirebaseFirestore.Firestore)\n return {\n refusal: async ({ hostId, uid }) => {\n const host = await firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return { status: 404, error: 'Unknown site' }\n return campaignDraftRoleRefusal(host, uid)\n },\n\n check: (content) => {\n const read = readCampaignDraftContent(content)\n return read.ok === false\n ? read\n : { ok: true, facts: { fields: [...CAMPAIGN_DRAFT_EMAIL_FIELDS] } }\n },\n\n read: async ({ hostId, id }) => {\n if (!isDocumentId(id)) return null\n const campaign = await firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('emailCampaigns')\n .doc(id)\n .get()\n return campaign.exists ? campaignDraftRecord(campaign) : null\n },\n\n write: async (request): Promise<PluginDraftWrite> => {\n const read = readCampaignDraftContent(request.content)\n if (read.ok === false) return { ok: false, status: 400, error: read.problems[0] }\n if (!isDocumentId(request.id)) return { ok: false, status: 400, error: 'Invalid campaignId' }\n const db = firestore()\n const hostRef = db.collection('hosts').doc(request.hostId)\n const campaignRef = hostRef.collection('emailCampaigns').doc(request.id)\n const emailId = campaignDraftEmailId(request.id)\n const emailRef = hostRef.collection('campaigns').doc(emailId)\n const designRef = hostRef.collection('screens').doc(read.value.templateScreenId)\n return db.runTransaction(async (transaction): Promise<PluginDraftWrite> => {\n const [host, campaign, email, design] = await Promise.all([\n transaction.get(hostRef),\n transaction.get(campaignRef),\n transaction.get(emailRef),\n transaction.get(designRef),\n ])\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (campaign.exists) {\n return { ok: true, replayed: true, ...campaignDraftRecord(campaign) }\n }\n const refusal = campaignDraftRoleRefusal(host, request.uid)\n if (refusal) return { ok: false, ...refusal }\n if (\n !design.exists ||\n design.get('deletedAt') != null ||\n design.get('kind') !== SCREEN_KIND_EMAIL\n ) {\n return { ok: false, status: 400, error: 'Unknown email design' }\n }\n if (email.exists) return { ok: false, status: 409, error: 'That email already exists' }\n const name = draftHeaderLine(request.name) || CAMPAIGN_DRAFT_DEFAULT_NAME\n const nowMs = request.now.getTime()\n // The create drawer's own document: a name, no dates, no lists.\n transaction.create(campaignRef, {\n name,\n listIds: [],\n createdAtMs: nowMs,\n createdBy: request.uid,\n })\n const { value } = read\n transaction.create(emailRef, {\n subject: value.subject,\n ...(value.preheader ? { preheader: value.preheader } : {}),\n templateScreenId: value.templateScreenId,\n ...(value.subjectVariants.length ? { subjectVariants: value.subjectVariants } : {}),\n ...(value.preheaderVariants.length ? { preheaderVariants: value.preheaderVariants } : {}),\n [CAMPAIGN_SEND_CONTAINER_FIELD]: request.id,\n status: 'draft',\n createdAtMs: nowMs,\n draftedAt: request.now,\n draftedBy: request.uid,\n })\n return {\n ok: true,\n replayed: false,\n id: request.id,\n name,\n versionId: null,\n facts: { emailId },\n }\n })\n },\n }\n}\n\nexport const campaignDraftWriter = createCampaignDraftWriter()\n\n/**\n * Registers the writer; the console surface calls it, since only the console\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerCampaignDraftWriter(): void {\n registerPluginResourceDraftWriter(CAMPAIGN_DRAFT_RESOURCE, campaignDraftWriter, {\n pluginId: MARKETING_PLUGIN_ID,\n })\n}\n\nexport const campaignManageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const action = String(req.body?.action ?? '')\n const targetId = String(req.body?.campaignId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n if (!isDocumentId(targetId)) {\n return res.status(400).json({ error: 'Invalid campaignId' })\n }\n if (\n action !== 'deleteCampaign' &&\n action !== 'discardEmail' &&\n action !== 'duplicate'\n ) {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n if (action === 'duplicate') {\n // One attempt key, one copy (AGL-2936): a double click on Duplicate\n // replays the first answer rather than drafting the message twice.\n const attemptKey = String(req.body?.attemptKey ?? '').trim().slice(0, 200)\n const orgId = (await resolveOrgIdForHost(hostId)) ?? ''\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: 'duplicate-campaign',\n scopeId: `${hostId}:${targetId}`,\n orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n const copied = await duplicateMessage(\n hostId,\n hostRef,\n targetId,\n String(req.body?.name ?? ''),\n { uid: decoded.uid, email: decoded.email ?? null, orgId },\n )\n if (copied.status === 200) await claim?.record(copied.status, copied.body)\n else await claim?.release()\n return res.status(copied.status).json(copied.body)\n }\n const result =\n action === 'deleteCampaign'\n ? await deleteCampaign(hostId, hostRef, targetId)\n : await discardDraft(hostRef, targetId)\n return res.status(result.status).json(result.body)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'The request could not be completed' })\n }\n}\n"],"names":["firebaseAdmin","isDocumentId","logResourceDuplicated","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","uniqueDuplicateName","claimAttempt","createResourceUid","consentGroupForSite","orgDataCollectionForHost","resolveOrgIdForHost","CAMPAIGN_MEMBER_HOST_COLLECTIONS","CAMPAIGN_MEMBERSHIP_FIELD","contactCampaignFieldPath","CAMPAIGN_SEND_CONTAINER_FIELD","SCREEN_KIND_EMAIL","registerPluginResourceDraftWriter","runPluginMembershipDetachers","DETACH_BATCH","DETACH_PASSES","detachSends","hostRef","campaignId","firestore","detached","pass","page","collection","where","limit","get","empty","remaining","batch","send","docs","update","ref","FieldValue","delete","commit","size","detachMembership","collectionRef","fieldPath","member","arrayRemove","detachMembers","hostId","collectionName","orgId","plugins","field","id","report","Object","values","group","contacts","contactPass","groupId","deleteCampaign","doc","snapshot","exists","status","body","error","toLocaleString","members","detachedMembers","deleted","discardDraft","emailId","refusal","state","outcome","runTransaction","transaction","fresh","String","discarded","DUPLICATED_MESSAGE_FIELDS","duplicateMessage","requestedName","actor","messages","source","data","siblings","Promise","all","select","sourceName","trim","name","map","row","copy","undefined","nowMs","Date","now","create","displayName","createdAtMs","draftedAt","draftedBy","uid","email","target","CAMPAIGN_DRAFT_RESOURCE","MARKETING_PLUGIN_ID","CAMPAIGN_DRAFT_DEFAULT_NAME","CAMPAIGN_DRAFT_HEADER_MAX_CHARS","CAMPAIGN_DRAFT_MAX_VARIANTS","CAMPAIGN_DRAFT_ROLES","CAMPAIGN_DRAFT_ROLE_REFUSAL","campaignDraftEmailId","CAMPAIGN_DRAFT_EMAIL_FIELDS","draftHeaderLine","value","replace","draftVariants","Array","isArray","seen","Set","lines","raw","line","has","toLowerCase","add","push","slice","readCampaignDraftContent","content","templateScreenId","subject","preheader","subjectVariants","preheaderVariants","problems","long","some","length","ok","campaignDraftRoleRefusal","host","role","includes","campaignDraftRecord","campaign","versionId","facts","createCampaignDraftWriter","deps","app","check","read","fields","write","request","db","campaignRef","emailRef","designRef","design","replayed","getTime","listIds","createdBy","campaignDraftWriter","registerCampaignDraftWriter","pluginId","campaignManageHandler","req","res","method","json","action","targetId","authorization","headers","idToken","startsWith","hostSnapshot","decoded","auth","verifyIdToken","memberRole","attemptKey","claimed","kind","scopeId","key","busyMessage","replay","claim","copied","record","release","result","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqDC,GAED;;;;;;CAMC,GACD,OAAOA,mBAAmB,iDAAgD;AAC1E,SAASC,YAAY,QAAQ,8CAA6C;AAC1E,SAASC,qBAAqB,QAAQ,qDAAoD;AAC1F,SACEC,sBAAsB,EACtBC,oBAAoB,EACpBC,mBAAmB,QACd,4CAA2C;AAClD,SAASC,YAAY,EAAEC,iBAAiB,QAAQ,sBAAqB;AACrE;;;;;CAKC,GACD,SACEC,mBAAmB,EACnBC,wBAAwB,EACxBC,mBAAmB,QACd,gDAA+C;AACtD,SACEC,gCAAgC,EAChCC,yBAAyB,EACzBC,wBAAwB,QAEnB,sBAAqB;AAC5B,SACEC,6BAA6B,QACxB,yCAAwC;AAC/C,SAASC,iBAAiB,QAAQ,sCAAqC;AACvE,SACEC,iCAAiC,QAK5B,qDAAoD;AAC3D,SAASC,4BAA4B,QAAQ,uDAAsD;AAEnG;;;;;;CAMC,GACD,MAAMC,eAAe;AAErB;;;;;;;;;;;;CAYC,GACD,MAAMC,gBAAgB;AAQtB;;;;;;;;;CASC,GACD,eAAeC,YACbC,OAA4C,EAC5CC,UAAkB;IAElB,MAAMC,YAAYF,QAAQE,SAAS;IACnC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,QAChBM,UAAU,CAAC,aACXC,KAAK,CAACd,+BAA+B,MAAMQ,YAC3CO,KAAK,CAACX,cACNY,GAAG;QACN,IAAIJ,KAAKK,KAAK,EAAE,OAAO;YAAEP;YAAUQ,WAAW;QAAM;QACpD,MAAMC,QAAQV,UAAUU,KAAK;QAC7B,KAAK,MAAMC,QAAQR,KAAKS,IAAI,CAAE;YAC5B;;;;;;;;OAQC,GACDF,MAAMG,MAAM,CAACF,KAAKG,GAAG,EAAE;gBACrB,CAACvB,8BAA8B,EAC7Bd,cAAcuB,SAAS,CAACe,UAAU,CAACC,MAAM;YAC7C;QACF;QACA,MAAMN,MAAMO,MAAM;QAClBhB,YAAYE,KAAKe,IAAI;QACrB,IAAIf,KAAKe,IAAI,GAAGvB,cAAc,OAAO;YAAEM;YAAUQ,WAAW;QAAM;IACpE;IACA,OAAO;QAAER;QAAUQ,WAAW;IAAK;AACrC;AAEA;;;;;;;;;;;;;;;CAeC,GACD,eAAeU,iBACbnB,SAAsC,EACtCoB,aAAoD,EACpDC,SAAiB,EACjBtB,UAAkB;IAElB,IAAIE,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAMiB,cAChBf,KAAK,CAACgB,WAAW,kBAAkBtB,YACnCO,KAAK,CAACX,cACNY,GAAG;QACN,IAAIJ,KAAKK,KAAK,EAAE,OAAO;YAAEP;YAAUQ,WAAW;QAAM;QACpD,MAAMC,QAAQV,UAAUU,KAAK;QAC7B,KAAK,MAAMY,UAAUnB,KAAKS,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACS,OAAOR,GAAG,EAAE;gBACvB,CAACO,UAAU,EAAE5C,cAAcuB,SAAS,CAACe,UAAU,CAACQ,WAAW,CAACxB;YAC9D;QACF;QACA,MAAMW,MAAMO,MAAM;QAClBhB,YAAYE,KAAKe,IAAI;QACrB,IAAIf,KAAKe,IAAI,GAAGvB,cAAc,OAAO;YAAEM;YAAUQ,WAAW;QAAM;IACpE;IACA,OAAO;QAAER;QAAUQ,WAAW;IAAK;AACrC;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GACD,eAAee,cACbC,MAAc,EACd3B,OAA4C,EAC5CC,UAAkB;IAElB,MAAMC,YAAYF,QAAQE,SAAS;IACnC,IAAIC,WAAW;IACf,IAAIQ,YAAY;IAChB,KAAK,MAAMiB,kBAAkBtC,iCAAkC;QAC7D,MAAMc,OAAO,MAAMiB,iBACjBnB,WACAF,QAAQM,UAAU,CAACsB,iBACnBrC,2BACAU;QAEFE,YAAYC,KAAKD,QAAQ;QACzBQ,YAAYA,aAAaP,KAAKO,SAAS;IACzC;IACA;;;;;;;;;;GAUC,GACD,MAAMkB,QAAQ,MAAMxC,oBAAoBsC;IACxC,MAAMG,UAAU,MAAMlC,6BAA6B;QACjD+B;QACAE,KAAK,EAAEA,gBAAAA,QAAS;QAChBE,OAAOxC;QACPyC,IAAI/B;IACN;IACA,KAAK,MAAMgC,UAAUC,OAAOC,MAAM,CAACL,SAAU;;QAC3C3B,oBAAY8B,0BAAAA,OAAQ9B,QAAQ,mBAAI;QAChCQ,YAAYA,aAAasB,WAAW,QAAQA,OAAOtB,SAAS;IAC9D;IACA,IAAI,CAACkB,OAAO,OAAO;QAAE1B;QAAUQ;IAAU;IACzC,MAAMyB,QAAQ,MAAMjD,oBAAoBwC;IACxC,MAAMU,WAAW,MAAMjD,yBAAyBuC,QAAQ;IACxD,MAAMW,cAAc,MAAMjB,iBACxBnB,WACAmC,UACA7C,yBAAyB4C,MAAMG,OAAO,GACtCtC;IAEF,OAAO;QACLE,UAAUA,WAAWmC,YAAYnC,QAAQ;QACzCQ,WAAWA,aAAa2B,YAAY3B,SAAS;IAC/C;AACF;AAEA;;;;;;;;CAQC,GACD,eAAe6B,eACbb,MAAc,EACd3B,OAA4C,EAC5CC,UAAkB;IAElB,MAAMe,MAAMhB,QAAQM,UAAU,CAAC,kBAAkBmC,GAAG,CAACxC;IACrD,MAAMyC,WAAW,MAAM1B,IAAIP,GAAG;IAC9B;;;;;;GAMC,GACD,IAAI,CAACiC,SAASC,MAAM,EAAE;QACpB,OAAO;YAAEC,QAAQ;YAAKC,MAAM;gBAAEC,OAAO;YAAmB;QAAE;IAC5D;IACA,MAAM,EAAE3C,QAAQ,EAAEQ,SAAS,EAAE,GAAG,MAAMZ,YAAYC,SAASC;IAC3D,IAAIU,WAAW;QACb,OAAO;YACLiC,QAAQ;YACRC,MAAM;gBACJ1C;gBACA2C,OACE,CAAC,6DAA6D,CAAC,GAC/D,GAAG3C,SAAS4C,cAAc,GAAG,sCAAsC,CAAC,GACpE;YACJ;QACF;IACF;IACA;;;;;;;;;;GAUC,GACD,MAAMC,UAAU,MAAMtB,cAAcC,QAAQ3B,SAASC;IACrD,IAAI+C,QAAQrC,SAAS,EAAE;QACrB,OAAO;YACLiC,QAAQ;YACRC,MAAM;gBACJ1C;gBACA8C,iBAAiBD,QAAQ7C,QAAQ;gBACjC2C,OACE,kEACA,GAAGE,QAAQ7C,QAAQ,CAAC4C,cAAc,GAAG,4BAA4B,CAAC,GAClE,iEACA;YACJ;QACF;IACF;IACA,MAAM/B,IAAIE,MAAM;IAChB,OAAO;QACL0B,QAAQ;QACRC,MAAM;YACJ5C;YACAE;YACA8C,iBAAiBD,QAAQ7C,QAAQ;YACjC+C,SAAS;QACX;IACF;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,eAAeC,aACbnD,OAA4C,EAC5CoD,OAAe;IAEf,MAAMpC,MAAMhB,QAAQM,UAAU,CAAC,aAAamC,GAAG,CAACW;IAChD,MAAMlD,YAAYF,QAAQE,SAAS;IACnC,MAAMmD,UAAU,CAACC;QACf,IAAIA,UAAU,QAAQ;YACpB,OAAO,kEACL;QACJ;QACA,IAAIA,UAAU,aAAa;YACzB,OAAO,iEACL;QACJ;QACA,IAAIA,UAAU,WAAW;YACvB,OAAO;QACT;QACA,OAAO;IACT;IACA,MAAMC,UAAU,MAAMrD,UAAUsD,cAAc,CAC5C,OAAOC;YAKgBC;QAJrB,MAAMA,QAAQ,MAAMD,YAAYhD,GAAG,CAACO;QACpC,IAAI,CAAC0C,MAAMf,MAAM,EAAE;YACjB,OAAO;gBAAEC,QAAQ;gBAAKC,MAAM;oBAAEC,OAAO;gBAAgB;YAAE;QACzD;QACA,MAAMQ,QAAQK,QAAOD,aAAAA,MAAMjD,GAAG,CAAC,qBAAViD,aAAuB;QAC5C,IAAIJ,UAAU,SAAS;YACrB,OAAO;gBAAEV,QAAQ;gBAAKC,MAAM;oBAAEC,OAAOO,QAAQC;gBAAO;YAAE;QACxD;QACA;;;;;OAKC,GACDG,YAAYvC,MAAM,CAACF;QACnB,OAAO;YAAE4B,QAAQ;YAAKC,MAAM;gBAAEO;gBAASQ,WAAW;YAAK;QAAE;IAC3D;IAEF,OAAOL;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD;;;;;;CAMC,GACD,MAAMM,4BAA4B;IAChC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACApE;CACD;AAED;;;;CAIC,GACD,eAAeqE,iBACbnC,MAAc,EACd3B,OAA4C,EAC5CoD,OAAe,EACfW,aAAqB,EACrBC,KAA2D;IAE3D,MAAMC,WAAWjE,QAAQM,UAAU,CAAC;IACpC,MAAMJ,YAAYF,QAAQE,SAAS;IACnC,MAAM8B,KAAK9C;IACX,MAAMqE,UAAU,MAAMrD,UAAUsD,cAAc,CAC5C,OAAOC;YAQSS,cACYC,MAAAA;QAR1B,MAAM,CAACD,QAAQE,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC3Cb,YAAYhD,GAAG,CAACwD,SAASxB,GAAG,CAACW;YAC7BK,YAAYhD,GAAG,CAACwD,SAASM,MAAM,CAAC,eAAe;SAChD;QACD,IAAI,CAACL,OAAOvB,MAAM,EAAE;YAClB,OAAO;gBAAEC,QAAQ;gBAAKC,MAAM;oBAAEC,OAAO;gBAAgB;YAAE;QACzD;QACA,MAAMqB,QAAQD,eAAAA,OAAOC,IAAI,cAAXD,eAAiB,CAAC;QAChC,MAAMM,aAAab,QAAOQ,QAAAA,oBAAAA,IAAI,CAAC,cAAc,YAAnBA,oBAAuBA,IAAI,CAAC,UAAU,YAAtCA,OAA0C,IAAIM,IAAI;QAC5E,MAAMC,OAAO1F,oBACX+E,cAAcU,IAAI,MAAM1F,qBAAqByF,aAC7CJ,SAAStD,IAAI,CAAC6D,GAAG,CAAC,CAACC;gBAAeA,MAAAA;mBAAPjB,QAAOiB,QAAAA,WAAAA,IAAInE,GAAG,CAAC,0BAARmE,WAA0BA,IAAInE,GAAG,CAAC,sBAAlCmE,OAAgD;;QAEpF,MAAMC,OAAgC,CAAC;QACvC,KAAK,MAAM9C,SAAS8B,0BAA2B;YAC7C,IAAIM,IAAI,CAACpC,MAAM,KAAK+C,aAAaX,IAAI,CAACpC,MAAM,KAAK,MAAM8C,IAAI,CAAC9C,MAAM,GAAGoC,IAAI,CAACpC,MAAM;QAClF;QACA,MAAMgD,QAAQC,KAAKC,GAAG;QACtBxB,YAAYyB,MAAM,CAACjB,SAASxB,GAAG,CAACT,KAAK,aAChC6C;YACHM,aAAaT;YACb9B,QAAQ;YACRwC,aAAaL;YACbM,WAAW,IAAIL,KAAKD;YACpBO,WAAWtB,MAAMuB,GAAG;;QAEtB,OAAO;YAAE3C,QAAQ;YAAKC,MAAM;gBAAEO,SAASpB;gBAAI0C;YAAK;YAAGA;YAAMF;QAAW;IACtE;IAEF,IAAIjB,QAAQX,MAAM,KAAK,KAAK;YAOOW,qBACTA;QAPxB,MAAM1E,sBACJ,YACA;YAAE0G,KAAKvB,MAAMuB,GAAG;YAAEC,OAAOxB,MAAMwB,KAAK;QAAC,GACrC;YACE3D,OAAOmC,MAAMnC,KAAK;YAClBF;YACAuC,QAAQ;gBAAElC,IAAIoB;gBAASsB,IAAI,GAAEnB,sBAAAA,QAAQiB,UAAU,YAAlBjB,sBAAsB;YAAG;YACtDkC,QAAQ;gBAAEzD;gBAAI0C,IAAI,GAAEnB,gBAAAA,QAAQmB,IAAI,YAAZnB,gBAAgB;YAAG;QACzC;IAEJ;IACA,OAAO;QAAEX,QAAQW,QAAQX,MAAM;QAAEC,MAAMU,QAAQV,IAAI;IAAC;AACtD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAiC2C,GAE3C,qEAAqE,GACrE,OAAO,MAAM6C,0BAA0B,WAAU;AAEjD,8DAA8D,GAC9D,MAAMC,sBAAsB;AAE5B,6DAA6D,GAC7D,OAAO,MAAMC,8BAA8B,oBAAmB;AAE9D,0FAA0F,GAC1F,OAAO,MAAMC,kCAAkC,IAAG;AAElD,4EAA4E,GAC5E,OAAO,MAAMC,8BAA8B,GAAE;AAE7C,sDAAsD,GACtD,OAAO,MAAMC,uBAAuB;IAAC;IAAS;CAAS,CAAS;AAChE,gFAAgF,GAChF,OAAO,MAAMC,8BAA8B,6BAA4B;AAEvE,uDAAuD,GACvD,OAAO,SAASC,qBAAqBhG,UAAkB;IACrD,OAAO,GAAGA,WAAW,MAAM,CAAC;AAC9B;AAEA,iFAAiF,GACjF,OAAO,MAAMiG,8BAA8B;IACzC;IACA;IACA;IACA;IACA;IACAzG;IACA;IACA;IACA;IACA;CACD,CAAS;AAYV,gFAAgF,GAChF,SAAS0G,gBAAgBC,KAAc;IACrC,OAAOzC,OAAOyC,gBAAAA,QAAS,IAAIC,OAAO,CAAC,sDAAiB,KAAK5B,IAAI;AAC/D;AAEA,SAAS6B,cAAcF,KAAc;IACnC,IAAI,CAACG,MAAMC,OAAO,CAACJ,QAAQ,OAAO,EAAE;IACpC,MAAMK,OAAO,IAAIC;IACjB,MAAMC,QAAkB,EAAE;IAC1B,KAAK,MAAMC,OAAOR,MAAO;QACvB,MAAMS,OAAOV,gBAAgBS;QAC7B,IAAI,CAACC,QAAQJ,KAAKK,GAAG,CAACD,KAAKE,WAAW,KAAK;QAC3CN,KAAKO,GAAG,CAACH,KAAKE,WAAW;QACzBJ,MAAMM,IAAI,CAACJ;IACb;IACA,OAAOF,MAAMO,KAAK,CAAC,GAAGpB;AACxB;AAEA,OAAO,SAASqB,yBACdC,OAA0C;QAGfA;IAD3B,MAAMhB,QAA8B;QAClCiB,kBAAkB1D,QAAOyD,4BAAAA,OAAO,CAAC,mBAAmB,YAA3BA,4BAA+B;QACxDE,SAASnB,gBAAgBiB,OAAO,CAAC,UAAU;QAC3CG,WAAWpB,gBAAgBiB,OAAO,CAAC,YAAY;QAC/CI,iBAAiBlB,cAAcc,OAAO,CAAC,kBAAkB;QACzDK,mBAAmBnB,cAAcc,OAAO,CAAC,oBAAoB;IAC/D;IACA,MAAMM,WAAqB,EAAE;IAC7B,IAAI,CAAC9I,aAAawH,MAAMiB,gBAAgB,GAAG;QACzCK,SAAST,IAAI,CAAC;IAChB;IACA,IAAI,CAACb,MAAMkB,OAAO,EAAEI,SAAST,IAAI,CAAC;IAClC,MAAMU,OAAO,CAAChB,QACZA,MAAMiB,IAAI,CAAC,CAACf,OAASA,KAAKgB,MAAM,GAAGhC;IACrC,IAAI8B,KAAK;QAACvB,MAAMkB,OAAO;WAAKlB,MAAMoB,eAAe;KAAC,GAAG;QACnDE,SAAST,IAAI,CAAC,CAAC,8BAA8B,EAAEpB,gCAAgC,WAAW,CAAC;IAC7F;IACA,IAAI8B,KAAK;QAACvB,MAAMmB,SAAS;WAAKnB,MAAMqB,iBAAiB;KAAC,GAAG;QACvDC,SAAST,IAAI,CAAC,CAAC,2BAA2B,EAAEpB,gCAAgC,WAAW,CAAC;IAC1F;IACA,OAAO6B,SAASG,MAAM,GAAG;QAAEC,IAAI;QAAOJ;IAAS,IAAI;QAAEI,IAAI;QAAM1B;IAAM;AACvE;AAEA,SAAS2B,yBACPC,IAAwC,EACxCzC,GAAW;QAEGyC;IAAd,MAAMC,OAAO,EAACD,YAAAA,KAAKvH,GAAG,CAAC,0BAATuH,YAA2B,CAAC,EAAE,CAACzC,IAAI;IACjD,OAAOQ,qBAAqBmC,QAAQ,CAACD,QACjC,OACA;QAAErF,QAAQ;QAAKE,OAAOkD;IAA4B;AACxD;AAEA,SAASmC,oBAAoBC,QAA4C;QAGxDA;IAFf,OAAO;QACLpG,IAAIoG,SAASpG,EAAE;QACf0C,MAAMf,QAAOyE,gBAAAA,SAAS3H,GAAG,CAAC,mBAAb2H,gBAAwB;QACrCC,WAAW;QACXC,OAAO;YAAElF,SAAS6C,qBAAqBmC,SAASpG,EAAE;QAAE;IACtD;AACF;AAOA,OAAO,SAASuG,0BACdC,OAAgC,CAAC,CAAC;QAGhCA;IADF,MAAMtI,aACJsI,kBAAAA,KAAKtI,SAAS,YAAdsI,kBACC,IAAM7J,cAAc8J,GAAG,GAAGvI,SAAS;IACtC,OAAO;QACLmD,SAAS,OAAO,EAAE1B,MAAM,EAAE4D,GAAG,EAAE;YAC7B,MAAMyC,OAAO,MAAM9H,YAAYI,UAAU,CAAC,SAASmC,GAAG,CAACd,QAAQlB,GAAG;YAClE,IAAI,CAACuH,KAAKrF,MAAM,EAAE,OAAO;gBAAEC,QAAQ;gBAAKE,OAAO;YAAe;YAC9D,OAAOiF,yBAAyBC,MAAMzC;QACxC;QAEAmD,OAAO,CAACtB;YACN,MAAMuB,OAAOxB,yBAAyBC;YACtC,OAAOuB,KAAKb,EAAE,KAAK,QACfa,OACA;gBAAEb,IAAI;gBAAMQ,OAAO;oBAAEM,QAAQ;2BAAI1C;qBAA4B;gBAAC;YAAE;QACtE;QAEAyC,MAAM,OAAO,EAAEhH,MAAM,EAAEK,EAAE,EAAE;YACzB,IAAI,CAACpD,aAAaoD,KAAK,OAAO;YAC9B,MAAMoG,WAAW,MAAMlI,YACpBI,UAAU,CAAC,SACXmC,GAAG,CAACd,QACJrB,UAAU,CAAC,kBACXmC,GAAG,CAACT,IACJvB,GAAG;YACN,OAAO2H,SAASzF,MAAM,GAAGwF,oBAAoBC,YAAY;QAC3D;QAEAS,OAAO,OAAOC;YACZ,MAAMH,OAAOxB,yBAAyB2B,QAAQ1B,OAAO;YACrD,IAAIuB,KAAKb,EAAE,KAAK,OAAO,OAAO;gBAAEA,IAAI;gBAAOlF,QAAQ;gBAAKE,OAAO6F,KAAKjB,QAAQ,CAAC,EAAE;YAAC;YAChF,IAAI,CAAC9I,aAAakK,QAAQ9G,EAAE,GAAG,OAAO;gBAAE8F,IAAI;gBAAOlF,QAAQ;gBAAKE,OAAO;YAAqB;YAC5F,MAAMiG,KAAK7I;YACX,MAAMF,UAAU+I,GAAGzI,UAAU,CAAC,SAASmC,GAAG,CAACqG,QAAQnH,MAAM;YACzD,MAAMqH,cAAchJ,QAAQM,UAAU,CAAC,kBAAkBmC,GAAG,CAACqG,QAAQ9G,EAAE;YACvE,MAAMoB,UAAU6C,qBAAqB6C,QAAQ9G,EAAE;YAC/C,MAAMiH,WAAWjJ,QAAQM,UAAU,CAAC,aAAamC,GAAG,CAACW;YACrD,MAAM8F,YAAYlJ,QAAQM,UAAU,CAAC,WAAWmC,GAAG,CAACkG,KAAKvC,KAAK,CAACiB,gBAAgB;YAC/E,OAAO0B,GAAGvF,cAAc,CAAC,OAAOC;gBAC9B,MAAM,CAACuE,MAAMI,UAAU5C,OAAO2D,OAAO,GAAG,MAAM9E,QAAQC,GAAG,CAAC;oBACxDb,YAAYhD,GAAG,CAACT;oBAChByD,YAAYhD,GAAG,CAACuI;oBAChBvF,YAAYhD,GAAG,CAACwI;oBAChBxF,YAAYhD,GAAG,CAACyI;iBACjB;gBACD,IAAI,CAAClB,KAAKrF,MAAM,EAAE,OAAO;oBAAEmF,IAAI;oBAAOlF,QAAQ;oBAAKE,OAAO;gBAAe;gBACzE,IAAIsF,SAASzF,MAAM,EAAE;oBACnB,OAAO;wBAAEmF,IAAI;wBAAMsB,UAAU;uBAASjB,oBAAoBC;gBAC5D;gBACA,MAAM/E,UAAU0E,yBAAyBC,MAAMc,QAAQvD,GAAG;gBAC1D,IAAIlC,SAAS,OAAO;oBAAEyE,IAAI;mBAAUzE;gBACpC,IACE,CAAC8F,OAAOxG,MAAM,IACdwG,OAAO1I,GAAG,CAAC,gBAAgB,QAC3B0I,OAAO1I,GAAG,CAAC,YAAYf,mBACvB;oBACA,OAAO;wBAAEoI,IAAI;wBAAOlF,QAAQ;wBAAKE,OAAO;oBAAuB;gBACjE;gBACA,IAAI0C,MAAM7C,MAAM,EAAE,OAAO;oBAAEmF,IAAI;oBAAOlF,QAAQ;oBAAKE,OAAO;gBAA4B;gBACtF,MAAM4B,OAAOyB,gBAAgB2C,QAAQpE,IAAI,KAAKkB;gBAC9C,MAAMb,QAAQ+D,QAAQ7D,GAAG,CAACoE,OAAO;gBACjC,gEAAgE;gBAChE5F,YAAYyB,MAAM,CAAC8D,aAAa;oBAC9BtE;oBACA4E,SAAS,EAAE;oBACXlE,aAAaL;oBACbwE,WAAWT,QAAQvD,GAAG;gBACxB;gBACA,MAAM,EAAEa,KAAK,EAAE,GAAGuC;gBAClBlF,YAAYyB,MAAM,CAAC+D,UAAU;oBAC3B3B,SAASlB,MAAMkB,OAAO;mBAClBlB,MAAMmB,SAAS,GAAG;oBAAEA,WAAWnB,MAAMmB,SAAS;gBAAC,IAAI,CAAC;oBACxDF,kBAAkBjB,MAAMiB,gBAAgB;mBACpCjB,MAAMoB,eAAe,CAACK,MAAM,GAAG;oBAAEL,iBAAiBpB,MAAMoB,eAAe;gBAAC,IAAI,CAAC,GAC7EpB,MAAMqB,iBAAiB,CAACI,MAAM,GAAG;oBAAEJ,mBAAmBrB,MAAMqB,iBAAiB;gBAAC,IAAI,CAAC;oBACvF,CAAChI,8BAA8B,EAAEqJ,QAAQ9G,EAAE;oBAC3CY,QAAQ;oBACRwC,aAAaL;oBACbM,WAAWyD,QAAQ7D,GAAG;oBACtBK,WAAWwD,QAAQvD,GAAG;;gBAExB,OAAO;oBACLuC,IAAI;oBACJsB,UAAU;oBACVpH,IAAI8G,QAAQ9G,EAAE;oBACd0C;oBACA2D,WAAW;oBACXC,OAAO;wBAAElF;oBAAQ;gBACnB;YACF;QACF;IACF;AACF;AAEA,OAAO,MAAMoG,sBAAsBjB,4BAA2B;AAE9D;;;CAGC,GACD,OAAO,SAASkB;IACd9J,kCAAkC+F,yBAAyB8D,qBAAqB;QAC9EE,UAAU/D;IACZ;AACF;AAEA,OAAO,MAAMgE,wBAA0C,OAAOC,KAAKC;4BAmBpCD;QAfPA,WACAA,YACEA;IALxB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;YAAEjH,OAAO;QAAqB;IAC5D;IACA,MAAMnB,SAASgC,gBAAOiG,YAAAA,IAAI/G,IAAI,qBAAR+G,UAAUjI,MAAM,mBAAI;IAC1C,MAAMqI,SAASrG,iBAAOiG,aAAAA,IAAI/G,IAAI,qBAAR+G,WAAUI,MAAM,oBAAI;IAC1C,MAAMC,WAAWtG,iBAAOiG,aAAAA,IAAI/G,IAAI,qBAAR+G,WAAU3J,UAAU,oBAAI;IAChD,IAAI,CAAC0B,QAAQ,OAAOkI,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;QAAEjH,OAAO;IAAiB;IACnE,IAAI,CAAClE,aAAaqL,WAAW;QAC3B,OAAOJ,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;YAAEjH,OAAO;QAAqB;IAC5D;IACA,IACEkH,WAAW,oBACXA,WAAW,kBACXA,WAAW,aACX;QACA,OAAOH,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;YAAEjH,OAAO;QAAiB;IACxD;IAEA,MAAMoH,gBAAgBvG,QAAOiG,6BAAAA,IAAIO,OAAO,CAACD,aAAa,YAAzBN,6BAA6B;IAC1D,MAAMQ,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAchD,KAAK,CAAC,UAAUW,MAAM,IACpC/C;IACJ,IAAI,CAACsF,SAAS,OAAOP,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;QAAEjH,OAAO;IAAkB;IAErE,IAAI;YAQkBwH;QAPpB,MAAMC,UAAU,MAAM5L,cAAc8J,GAAG,GAAG+B,IAAI,GAAGC,aAAa,CAACL;QAC/D,MAAMlK,YAAYvB,cAAc8J,GAAG,GAAGvI,SAAS;QAC/C,MAAMF,UAAUE,UAAUI,UAAU,CAAC,SAASmC,GAAG,CAACd;QAClD,MAAM2I,eAAe,MAAMtK,QAAQS,GAAG;QACtC,IAAI,CAAC6J,aAAa3H,MAAM,EAAE;YACxB,OAAOkH,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;gBAAEjH,OAAO;YAAe;QACtD;QACA,MAAM4H,aAAa,EAACJ,oBAAAA,aAAa7J,GAAG,CAAC,0BAAjB6J,oBAAmC,CAAC,EAAE,CAACC,QAAQhF,GAAG,CAAC;QACvE,IAAImF,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAOb,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;gBAAEjH,OAAO;YAA6B;QACpE;QAEA,IAAIkH,WAAW,aAAa;uBAIX,cAmBcO;gBApBHX,YAmBjBA;YArBT,oEAAoE;YACpE,mEAAmE;YACnE,MAAMe,aAAahH,iBAAOiG,aAAAA,IAAI/G,IAAI,qBAAR+G,WAAUe,UAAU,oBAAI,IAAIlG,IAAI,GAAGyC,KAAK,CAAC,GAAG;YACtE,MAAMrF,SAAS,QAAA,MAAMxC,oBAAoBsC,mBAA1B,QAAsC;YACrD,MAAMiJ,UAAUD,aACZ,MAAM1L,aAAaiB,WAAoB;gBACrC2K,MAAM;gBACNC,SAAS,GAAGnJ,OAAO,CAAC,EAAEsI,UAAU;gBAChCpI;gBACAkJ,KAAKJ;gBACLK,aAAalM;YACf,KACA;YACJ,IAAI8L,WAAW,YAAYA,SAAS;gBAClC,OAAOf,IAAIjH,MAAM,CAACgI,QAAQK,MAAM,CAACrI,MAAM,EAAEmH,IAAI,CAACa,QAAQK,MAAM,CAACpI,IAAI;YACnE;YACA,MAAMqI,QAAQN,WAAW,WAAWA,UAAUA,QAAQM,KAAK,GAAG;YAC9D,MAAMC,SAAS,MAAMrH,iBACnBnC,QACA3B,SACAiK,UACAtG,iBAAOiG,aAAAA,IAAI/G,IAAI,qBAAR+G,WAAUlF,IAAI,oBAAI,KACzB;gBAAEa,KAAKgF,QAAQhF,GAAG;gBAAEC,KAAK,GAAE+E,iBAAAA,QAAQ/E,KAAK,YAAb+E,iBAAiB;gBAAM1I;YAAM;YAE1D,IAAIsJ,OAAOvI,MAAM,KAAK,KAAK,OAAMsI,yBAAAA,MAAOE,MAAM,CAACD,OAAOvI,MAAM,EAAEuI,OAAOtI,IAAI;iBACpE,OAAMqI,yBAAAA,MAAOG,OAAO;YACzB,OAAOxB,IAAIjH,MAAM,CAACuI,OAAOvI,MAAM,EAAEmH,IAAI,CAACoB,OAAOtI,IAAI;QACnD;QACA,MAAMyI,SACJtB,WAAW,mBACP,MAAMxH,eAAeb,QAAQ3B,SAASiK,YACtC,MAAM9G,aAAanD,SAASiK;QAClC,OAAOJ,IAAIjH,MAAM,CAAC0I,OAAO1I,MAAM,EAAEmH,IAAI,CAACuB,OAAOzI,IAAI;IACnD,EAAE,OAAOC,OAAO;QACdyI,QAAQzI,KAAK,CAACA;QACd,OAAO+G,IAAIjH,MAAM,CAAC,KAAKmH,IAAI,CAAC;YAAEjH,OAAO;QAAqC;IAC5E;AACF,EAAC"}
|