@aglyn/plugins-email 1.0.0-beta.250 → 1.0.0-beta.253

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-members-panel.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\n/**\n * Who is on one list, and the three things you can do about it.\n *\n * The audience card could create a list and delete a list. It could not show\n * you a single name on one. This panel is the rest of it: read the membership,\n * add somebody, take somebody off. The list's own settings — its name, and the\n * rule behind a dynamic one — belong to the edit route, because they are\n * facts about the LIST rather than about who is on it.\n *\n * ## It mounts only on a list's own page\n *\n * The audience detail route renders this for the ONE list being read. A\n * membership listener per row would open one per list on the audiences table,\n * on a surface where most visits are to read the list of lists — the\n * read-on-mount shape `emails-console-read-cost.spec.tsx` meters. Nothing here\n * reads anything until a list is opened, and the add form reads nothing until\n * an address is typed.\n *\n * ## Whether somebody may be ADDED is the server's answer, always\n *\n * Every sentence about an address being typed in is `email/list-members-preview`\n * answering from that person's actual record and both suppression lists. This\n * component decides none of it. A screen that decided for itself would be a\n * second copy of the rule on the one surface whose job is to tell the operator\n * the truth about what is about to happen — including, and especially, that\n * the product knows nothing about this person's permission.\n *\n * ## The membership table reads consent through the SEND-TIME reader\n *\n * The `Consent` column answers the same question `performCampaignSend` asks of\n * the same document, so it asks it with the same function —\n * {@link readMarketingBasis}, against the consent group this console is being\n * viewed as. A column that picked the fields out of the row itself is a second\n * reader of a stored shape, and the two only agree until the shape moves.\n *\n * ## Removing is not suppressing, and the screen says so\n *\n * Taking somebody off a list is the operator changing their mind about an\n * audience. It is not the person asking to stop being mailed, it does not stop\n * another list reaching them, and it writes nothing to the suppression list.\n * Those are different acts with different records, and a console that let them\n * blur is a console where \"remove\" is quietly relied on as an unsubscribe.\n */\n\nimport {\n readMarketingBasis,\n type ConsentGroup,\n type DynamicListRule,\n} from '@aglyn/aglyn'\nimport { mdiAccountRemoveOutline } from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, deleteDoc, doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useListColumnSort } from '@aglyn/shared-ui-jsx/hooks/use-list-column-sort'\nimport type { ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n LIST_MEMBER_FILTER_HEADERS,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_QUERY,\n} from '../constants/list-queries'\n\nexport interface ListMembersPanelProps {\n hostId: string\n /**\n * The controller this membership is being read AS — the declared group of\n * sites that are one sender, or this site alone.\n *\n * Required and undefaulted, for the reason {@link readMarketingBasis} makes\n * it required: a basis runs to a brand, so a surface that has not said which\n * brand is asking cannot report one. Defaulting to the group of one here\n * would compile and would then answer a declared group's questions with a\n * single site's facts — reporting a sibling's refusal as no record at all,\n * and a pooled grant as another brand's.\n */\n consentGroup: ConsentGroup\n /** `['orgs', orgId]` — the resolved org scope the card already holds. */\n scope: readonly [string, string]\n listId: string\n listName: string\n /**\n * Called after a membership is added or removed.\n *\n * The audience page's subscriber total is a server aggregate, taken once.\n * Left alone it would go on reporting the figure from before the add — a\n * stale total directly above a live table that disagrees with it, which\n * reads as the add having failed. The page re-takes the aggregate on this\n * signal and at no other time, so nothing is re-counted while somebody is\n * only reading.\n */\n onMembershipChanged?: () => void\n /**\n * The list's saved filters, when it has any and its membership is FIXED.\n *\n * Present, the panel offers to find the people they select and add them —\n * the same act as typing addresses, with the search doing the typing. Absent\n * (a live list, or one with no filters saved), the add form is the address\n * box alone: a live list's membership is the sweep's to decide, and offering\n * to add its own matches by hand would produce hand-added copies of rows the\n * sweep already owns.\n */\n findRule?: DynamicListRule | null\n /** The same filters in sentences, so the button says what it will find. */\n ruleSummary?: readonly string[]\n /**\n * Told what the table is filtered to — `null` when it is not — so the\n * card's Export can offer \"the current filter\": the predicates this\n * table's own query is asking, which the export reads with the same query.\n */\n onFilterChange?: (filter: ListMembersFilter | null) => void\n}\n\n/** The member table's filter, as the export dialog offers it. */\nexport interface ListMembersFilter {\n label: string\n value: { filters: ReadonlyArray<{ path: string; op: string; value: unknown }> }\n}\n\n/**\n * One member row, as stored.\n *\n * The consent fields are deliberately NOT enumerated here. They live one level\n * down, in an entry per site under `marketingConsentByHost`, and a row type\n * that named them at the top would be an invitation to read them there — which\n * is exactly what this table did while `enrollListMember` wrote them nested,\n * so every membership on every list reported \"No basis on record\" while the\n * send path mailed the same people on a basis it could see perfectly well. The\n * index signature is what {@link readMarketingBasis} takes; it is the only\n * reader of those fields on this surface.\n */\ninterface MemberRow {\n $id: string\n email?: string\n name?: string\n source?: string\n via?: 'manual' | 'rule'\n addedAt?: { toDate?: () => Date }\n [field: string]: unknown\n}\n\n/** What `email/list-members-preview` answers for one address. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\ninterface Preview {\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** What a filter search found, as `email/list-rule-preview` reports it. */\ninterface FoundPeople extends Preview {\n /** People the filters matched — NOT the length of `emails`. */\n matched: number\n /** `emails` is one batch of a larger match. */\n truncated: boolean\n /** False when the scan hit its budget, so `matched` is itself a floor. */\n complete: boolean\n emails: string[]\n}\n\n/** What one address's outcome was, after the add. */\ninterface AddResult {\n input: string\n email: string | null\n enrolled: boolean\n error?: string\n}\n\n/**\n * How the basis reads on screen, for the group this console is viewed as.\n *\n * ## Read through `readMarketingBasis`, never off the row\n *\n * This picked `marketingConsent`, `marketingConsentBasis` and\n * `marketingConsentAtMs` off the top of the membership document, which is\n * where `enrollListMember` used to put them. They now live in an entry per\n * site under `marketingConsentByHost`, because a basis runs to the brand it\n * was given to rather than to the account that holds the row. Nothing here\n * moved with them, so every top-level read answered `undefined` and every\n * member of every list — including people carrying a perfectly good attested\n * basis — was reported as having no basis on record, on the one screen an\n * operator consults before mailing them. `performCampaignSend` reads the same\n * documents through the function below and mailed them, correctly, at the same\n * time.\n *\n * The fix is not a second field name. It is that the send path's reader is the\n * only reader: a surface that parses the stored shape itself agrees with the\n * sender only until the shape moves, and it moves without anybody thinking to\n * look here.\n *\n * ## Five states, because the model carries five\n *\n * An attestation and an opt-in are not the same fact — one is a person's own\n * decision, the other a claim an account made on their behalf — and a table\n * rendering both as a tick is the conflation `list-members.ts` stores the\n * basis to prevent. The three that are not a grant are not one state either.\n * Absence is not refusal, and neither of them is \"opted in, to somebody else\n * in this account\": that third one is a person the sender deliberately leaves\n * alone, and an operator who cannot see why reads it as the product having\n * lost their subscriber.\n */\ntype ConsentKey =\n | 'opted-out'\n | 'attested'\n | 'opted-in'\n | 'other-site'\n | 'no-site'\n | 'none'\n\nfunction consentLabel(\n member: MemberRow,\n group: ConsentGroup,\n): { key: ConsentKey; label: string; color: 'default' | 'warning' | 'error' } {\n const record = readMarketingBasis(member as Record<string, unknown>, group)\n const when = record.basisAtMs\n ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}`\n : ''\n if (record.basis === 'declined') {\n return { key: 'opted-out', label: `Opted out${when}`, color: 'error' }\n }\n if (record.basis === 'granted') {\n /*\n * `assertedBy` rather than the stored basis field, because there are two\n * ways a record says an operator asserted it — a membership's\n * `marketingConsentBasis`, and the provenance object a backfill writes —\n * and the reader has already resolved both into this one answer.\n */\n return record.assertedBy === 'operator'\n ? { key: 'attested', label: `Attested by your team${when}`, color: 'warning' }\n : { key: 'opted-in', label: `Opted in${when}`, color: 'default' }\n }\n /*\n * A grant this site may not use. Reported rather than folded into \"no\n * basis\" because the two are different situations with different answers:\n * nobody has ever asked this person, versus somebody asked them on behalf\n * of another brand in this account and this brand may not borrow it.\n */\n if (record.otherGrant === 'other-host') {\n return { key: 'other-site', label: 'Opted in to another site', color: 'warning' }\n }\n if (record.otherGrant === 'unscoped') {\n return { key: 'no-site', label: 'Opted in — no site recorded', color: 'warning' }\n }\n return { key: 'none', label: 'No basis on record', color: 'default' }\n}\n\n/** How a member got here, by the words the column draws. */\nconst VIA_LABELS: Readonly<Record<'rule' | 'manual', string>> = {\n rule: 'Rule',\n manual: 'Added',\n}\n/** The How filter's choices are picked, so the panel shows a select. */\nconst MEMBER_SELECT_FIELDS = ['via']\n/** The page-sorted header, for its notice (AGL-3680). */\nconst MEMBER_SORT_HEADERS = { consent: 'Consent' }\n\n/** How a row reads `via`: a row that predates the field was added by hand. */\nconst viaOf = (member: MemberRow): 'rule' | 'manual' =>\n member.via === 'rule' ? 'rule' : 'manual'\n\n/** Free text — a paste, a typed address, commas or newlines — to addresses. */\nexport function splitAddresses(value: string): string[] {\n return value\n .split(/[\\s,;]+/)\n .map((entry) => entry.trim())\n // A pasted mail-client line is `Name <a@b.co>`; keep the address part.\n .map((entry) => entry.replace(/^.*<|>.*$/g, ''))\n .filter(Boolean)\n}\n\nexport function ListMembersPanel(props: ListMembersPanelProps) {\n const {\n hostId,\n consentGroup,\n scope,\n listId,\n listName,\n onMembershipChanged,\n findRule,\n ruleSummary,\n } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n /*\n * Ordered by DOCUMENT ID and paged, not capped and re-sorted here.\n *\n * `__name__` rather than `addedAt`, which is the column a reader would\n * assume: `orderBy` FILTERS as well as sorts, so a document missing the\n * field is not in the result at all — and `addedAt` is stamped only when a\n * membership is CREATED, so every row written under the two legacy ids\n * `list-members.ts` still adopts predates it. Ordering on it would drop the\n * oldest members from their own list, silently, which is the failure that\n * turned an audience into a random sample (AGL-2501). The id is the one key\n * every row has, so it stays the default. The Joined header asks for\n * `addedAt` only since the writer stamps it on every row it touches and\n * `backfill-list-member-sort-fields.mjs` on the rest (AGL-3680).\n *\n * Every filter and search word is a predicate on the same query\n * (AGL-3321) — see `LIST_MEMBER_QUERY` — so a page is a page of the\n * matches.\n */\n const gridFilter = useListGridFilter({ selectFields: MEMBER_SELECT_FIELDS })\n /*\n * EVERY HEADER SORTS (AGL-3680). Address, Name, Joined and How order the\n * QUERY — `enrollListMember` stamps each on every row, null where there is\n * none, so no order drops a member — each `alone`: under a filter or the\n * search the list keeps its id order and says so. Consent is read per\n * viewing consent group, so it sorts the page on screen.\n */\n const [askedSort, setAskedSort] = useState<ListQuerySort | null>(null)\n const {\n rows: members,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<MemberRow>({\n collection: collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n declaration: LIST_MEMBER_QUERY,\n request: {\n clauses: gridFilter.clauses,\n search: gridFilter.searchWords,\n sort: askedSort,\n },\n deps: [firestore, scope[0], scope[1], listId],\n idField: '$id',\n })\n const pageSorts = useMemo(\n () => ({ consent: (row: MemberRow) => consentLabel(row, consentGroup).label }),\n [consentGroup],\n )\n const columnSort = useListColumnSort<MemberRow>({\n sorts: LIST_MEMBER_QUERY.sorts,\n sort: askedSort,\n onSortChange: setAskedSort,\n orderBy: plan.orderBy,\n rows: members,\n pageSorts,\n headers: MEMBER_SORT_HEADERS,\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n const { onFilterChange } = props\n const filterKey = filtering ? JSON.stringify(plan.filters) : ''\n useEffect(() => {\n if (!onFilterChange) return\n onFilterChange(\n filterKey\n ? {\n label: 'Members matching the table’s filters',\n value: { filters: JSON.parse(filterKey) },\n }\n : null,\n )\n }, [filterKey, onFilterChange])\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: LIST_MEMBER_QUERY.fields,\n headers: LIST_MEMBER_FILTER_HEADERS,\n options: LIST_MEMBER_FILTER_OPTIONS,\n }),\n [plan.refused],\n )\n\n const [addInput, setAddInput] = useState('')\n const [addName, setAddName] = useState('')\n const [preview, setPreview] = useState<Preview | null>(null)\n const [attested, setAttested] = useState(false)\n const [results, setResults] = useState<AddResult[] | null>(null)\n const [busy, setBusy] = useState(false)\n /*\n * The addresses a SEARCH found, when the operator ran one.\n *\n * A second way to fill the same candidate set, not a second way to add. Once\n * this holds addresses, everything below it — the consent readout, the\n * attestation, the Add button — is the code the typed path already used, so\n * there is exactly one place in this component where somebody is put on a\n * list and exactly one gate in front of it.\n */\n const [found, setFound] = useState<FoundPeople | null>(null)\n\n const addresses = found ? found.emails : splitAddresses(addInput)\n\n /*\n * Any edit to the box invalidates the answer.\n *\n * A preview belongs to the exact addresses it was run for. Leaving a stale\n * one on screen would show a count, and an attestation checkbox, for a set\n * the operator has since changed — so the number they stand behind would\n * not be the number that acts. Typing also abandons a search: the two are\n * alternative ways of naming a candidate set, and holding both at once\n * would leave the Add button acting on whichever the code happened to\n * prefer.\n */\n useEffect(() => {\n setPreview(null)\n setAttested(false)\n setResults(null)\n setFound(null)\n }, [addInput])\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n const handlePreview = useCallback(async () => {\n if (busy || !addresses.length) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-preview', {\n emails: addresses,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The addresses could not be checked.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as Preview)\n } catch {\n enqueueSnackbar('The addresses could not be checked.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, addresses, post, enqueueSnackbar])\n\n const handleFind = useCallback(async () => {\n if (busy || !findRule) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-rule-preview', {\n rule: findRule,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The audience could not be worked out.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n /*\n * The verdicts come back from the SAME resolution the typed path runs,\n * so what lands in `preview` here is the same object the consent\n * readout below already knows how to draw. A search that returned its\n * own shaped summary would be a second consent readout, and the second\n * one is the one nobody reviews.\n */\n setFound(payload as FoundPeople)\n setPreview(payload as Preview)\n setAttested(false)\n setResults(null)\n if (!Number(payload?.matched ?? 0)) {\n enqueueSnackbar('These filters match nobody', {\n variant: 'info',\n persist: false,\n })\n }\n } catch {\n enqueueSnackbar('The audience could not be worked out.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, findRule, post, enqueueSnackbar])\n\n const handleAdd = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-add', {\n emails: addresses,\n ...(addresses.length === 1 && addName.trim()\n ? { name: addName.trim() }\n : {}),\n // The operator's assertion, not a basis they named: the server\n // derives a pass-through from each person's own record, and this flag\n // can only ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'Nobody was added to the list',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const added = Number(payload?.added ?? 0)\n setResults((payload?.results ?? []) as AddResult[])\n setPreview(null)\n if (added) {\n setAddInput('')\n setAddName('')\n setFound(null)\n onMembershipChanged?.()\n }\n enqueueSnackbar(\n added === 1 ? 'One person added' : `${added} people added`,\n { variant: added ? 'success' : 'warning', persist: false },\n )\n } catch {\n enqueueSnackbar('Nobody was added to the list', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [\n busy,\n preview,\n addresses,\n addName,\n attested,\n post,\n enqueueSnackbar,\n onMembershipChanged,\n ])\n\n const handleRemove = useCallback(\n async (member: MemberRow) => {\n const accepted = await confirm({\n title: 'Remove from this list?',\n description:\n `${member.email ?? 'This person'} stops being part of \"${listName}\" ` +\n 'and campaigns to this list stop reaching them. This is NOT an ' +\n 'unsubscribe: it does not add them to your suppression list, and ' +\n 'any other list they are on still reaches them. If they asked to ' +\n 'stop hearing from you, suppress the address instead.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone would make this always proceed (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n try {\n await deleteDoc(\n doc(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n listId,\n 'members',\n member.$id,\n ),\n )\n onMembershipChanged?.()\n enqueueSnackbar('Removed from the list', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('They were not removed', { variant: 'error' })\n }\n },\n [\n confirm,\n listName,\n firestore,\n scope,\n listId,\n enqueueSnackbar,\n onMembershipChanged,\n ],\n )\n\n const enrollable = preview\n ? preview.optedIn + preview.needAttestation\n : 0\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.email ?? '—',\n },\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 160,\n valueGetter: (_value, row) => row.name ?? '—',\n },\n {\n field: 'addedAt',\n headerName: 'Joined',\n width: 120,\n // Sorted on the instant, drawn as a local date.\n valueGetter: (_value, row) => row.addedAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row }) =>\n row.addedAt?.toDate ? row.addedAt.toDate().toLocaleDateString() : '—',\n },\n {\n field: 'via',\n headerName: 'How',\n width: 200,\n valueGetter: (_value, row) => viaOf(row),\n /*\n `via` says whether a rule put them here or a person did, which is what\n decides whether they LEAVE on their own: the materializer reconciles its\n own rows away and never touches a manual one. `source` is the finer\n provenance underneath it.\n */\n renderCell: ({ row }) => (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Chip size=\"small\" variant=\"outlined\" label={VIA_LABELS[viaOf(row)]} />\n {row.source ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.source}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'consent',\n headerName: 'Consent',\n width: 190,\n valueGetter: (_value, row) => consentLabel(row, consentGroup).label,\n renderCell: ({ row }) => {\n const consent = consentLabel(row, consentGroup)\n return (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={consent.color}\n label={consent.label}\n />\n )\n },\n },\n /*\n A SUBSCRIBER HAS NO PAGE of their own here, so the row opens nothing —\n but taking somebody off an audience is the one act performed on one, and\n it belongs in the trailing cluster with every other list's actions rather\n than as a red text button sitting in the row.\n */\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.email ?? row.$id)}\n items={[\n {\n key: 'remove',\n label: 'Remove from this list',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <Stack spacing={2} sx={{ py: 2 }}>\n {/*\n FINDING people, for a list whose membership is fixed.\n The filters are the same ones a live list is swept by; here they run\n once, on request, and what they find is offered for adding rather than\n enrolled. The count and the consent readout arrive together, because\n the second is the thing that decides how much of the first can act.\n */}\n {findRule ? (\n <Stack\n spacing={1}\n sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Find people'}\n </Typography>\n {(ruleSummary ?? []).map((clause) => (\n <Typography key={clause} variant=\"body2\">\n {clause}\n </Typography>\n ))}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleFind()}\n >\n {busy ? 'Searching…' : 'Find matching people'}\n </Button>\n </Box>\n {found ? (\n <Alert severity={found.matched ? 'info' : 'warning'}>\n {(found.matched === 1\n ? '1 person matches'\n : `${found.matched.toLocaleString()} people match`) +\n (found.truncated\n ? ` — this batch covers the first ${found.emails.length}. ` +\n 'Add them, then search again for the rest.'\n : '') +\n (found.complete\n ? ''\n : ' The search stopped at its read budget, so this is at ' +\n 'least that many rather than exactly.')}\n </Alert>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whoever you add stays on this list. It does not keep growing — ' +\n 'change the membership to live on the edit page if you want ' +\n 'new matches enrolled automatically.'}\n </Typography>\n </Stack>\n ) : null}\n\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Add someone by typing their address, or paste a column of them. ' +\n 'Every address is checked against their consent record and both ' +\n 'suppression lists before anyone is added.'}\n </Typography>\n\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"Email addresses\"\n placeholder=\"someone@example.com\"\n multiline\n minRows={1}\n maxRows={6}\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n helperText={\n addresses.length > 1\n ? `${addresses.length} addresses`\n : 'One address, or paste many'\n }\n sx={{ flexGrow: 1, minWidth: 260 }}\n />\n {addresses.length > 1 ? null : (\n <TextField\n size=\"small\"\n label=\"Name\"\n value={addName}\n onChange={(event) => setAddName(event.target.value)}\n sx={{ minWidth: 180 }}\n />\n )}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy || !addresses.length || Boolean(preview)}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check'}\n </Button>\n </Box>\n </Stack>\n\n {/*\n The count, in front of the operator, BEFORE the attestation control\n exists. One assertion covers the batch, so the number it covers has to\n be on screen when it is given — an attestation offered above an\n unexamined paste is a signature on a blank page.\n */}\n {preview ? (\n <Stack spacing={1}>\n <Alert severity={enrollable ? 'info' : 'warning'}>\n {[\n preview.optedIn\n ? `${preview.optedIn} already opted in`\n : '',\n preview.needAttestation\n ? `${preview.needAttestation} with no opt-in on record`\n : '',\n preview.refused\n ? `${preview.refused} cannot be added at all`\n : '',\n ]\n .filter(Boolean)\n .join(' · ')}\n </Alert>\n {/*\n The HARD refusals only. An address that merely needs the\n attestation is not a refusal — listing it here would put the very\n people the checkbox below is about under a heading that says they\n cannot be added.\n */}\n {preview.verdicts\n .filter((verdict) => verdict.refusal)\n .map((verdict) => (\n <Typography\n key={verdict.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${verdict.input} — ${verdict.summary}`}\n </Typography>\n ))}\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => setAttested(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {`I have permission to send marketing email to the ` +\n `${preview.needAttestation} ` +\n `${preview.needAttestation === 1 ? 'person' : 'people'} ` +\n 'above with no opt-in on record. This is recorded against ' +\n 'my account, with the date.'}\n </Typography>\n }\n />\n ) : null}\n <Box>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={\n busy ||\n !enrollable ||\n (preview.needAttestation > 0 && !attested)\n }\n onClick={() => void handleAdd()}\n >\n {busy ? 'Adding…' : `Add ${enrollable}`}\n </Button>\n </Box>\n </Stack>\n ) : null}\n\n {/*\n What actually happened, per address. A batch where one address was\n suppressed and forty were fine is not a failure, and a bare count would\n leave the operator to work out which of their lines is missing.\n */}\n {results?.length ? (\n <Stack spacing={0.5}>\n {results\n .filter((result) => !result.enrolled)\n .map((result) => (\n <Typography\n key={result.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${result.input} — not added. ${result.error ?? ''}`}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {members.length === 0 && !hasMore && !filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is on this list yet.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={LIST_MEMBER_QUERY.fields}\n headers={LIST_MEMBER_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={LIST_MEMBER_FILTER_OPTIONS}\n />\n <ListQueryNotices\n refused={refusals}\n notices={[...plan.notices, ...columnSort.notices]}\n />\n <ListTable\n aria-label={`Members of ${listName}`}\n rows={columnSort.rows}\n columnSort={columnSort}\n columns={listFilterGridColumns(\n columns,\n LIST_MEMBER_QUERY.fields,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel, the search and the headers go to the query (Consent\n // sorts the page, and says so); the grid itself sorts nothing.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n noRowsLabel=\"No members match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={members.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n\n {/*\n What the column MEANS, and what to do about the one state an operator\n can act on. \"No basis on record\" is a fact about this site's records,\n not a refusal and not a dead end — the remedy is the add form above,\n which merges onto the membership that is already here rather than\n creating a second one. Whether a basis-less member is actually mailed\n is the org's consent policy to decide and is deliberately not claimed\n here: this surface does not hold that setting, and a caption that\n guessed it would be wrong for every org on the other mode.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'\"No basis on record\" means nobody has recorded this person\\'s ' +\n 'permission for this site — it is not a refusal. To record one, put ' +\n 'their address in the box above and state that you have their ' +\n 'permission; the statement is kept against your account, with the ' +\n 'date.'}\n </Typography>\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Removing someone from a list is not an unsubscribe. It does not add ' +\n 'them to your suppression list, and it does not stop any other list ' +\n 'reaching them. If somebody asked you to stop, suppress the address.'}\n </Typography>\n </Stack>\n )\n}\nListMembersPanel.displayName = 'ListMembersPanel'\n\nexport default ListMembersPanel\n"],"names":["readMarketingBasis","mdiAccountRemoveOutline","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","Alert","Box","Button","Checkbox","Chip","FormControlLabel","Stack","TextField","Typography","collection","deleteDoc","doc","useCallback","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","useListColumnSort","authorizedFetch","LIST_MEMBER_FILTER_HEADERS","LIST_MEMBER_FILTER_OPTIONS","LIST_MEMBER_QUERY","consentLabel","member","group","record","when","basisAtMs","Date","toLocaleDateString","basis","key","label","color","assertedBy","otherGrant","VIA_LABELS","rule","manual","MEMBER_SELECT_FIELDS","MEMBER_SORT_HEADERS","consent","viaOf","via","splitAddresses","value","split","map","entry","trim","replace","filter","Boolean","ListMembersPanel","props","hostId","consentGroup","scope","listId","listName","onMembershipChanged","findRule","ruleSummary","firestore","data","user","enqueueSnackbar","confirm","gridFilter","selectFields","askedSort","setAskedSort","rows","members","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","sort","deps","idField","pageSorts","row","columnSort","sorts","onSortChange","orderBy","headers","filtering","length","some","word","onFilterChange","filterKey","JSON","stringify","filters","parse","refusals","refused","fields","options","addInput","setAddInput","addName","setAddName","preview","setPreview","attested","setAttested","results","setResults","busy","setBusy","found","setFound","addresses","emails","post","route","body","response","method","payload","json","catch","ok","handlePreview","error","variant","allowDuplicate","handleFind","Number","matched","persist","handleAdd","name","attestConsent","added","handleRemove","accepted","title","description","email","confirmationText","confirmationButtonProps","then","$id","console","enrollable","optedIn","needAttestation","columns","field","headerName","flex","minWidth","valueGetter","_value","width","addedAt","toDate","getTime","renderCell","direction","spacing","sx","alignItems","size","source","noWrap","String","items","icon","path","destructive","onClick","py","border","borderColor","borderRadius","p","clause","disabled","severity","toLocaleString","truncated","complete","useFlexGap","flexWrap","placeholder","multiline","minRows","maxRows","onChange","event","target","helperText","flexGrow","join","verdicts","verdict","refusal","input","summary","control","checked","result","enrolled","setClauses","notices","aria-label","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","noRowsLabel","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-members-panel.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\n/**\n * Who is on one list, and the three things you can do about it.\n *\n * The audience card could create a list and delete a list. It could not show\n * you a single name on one. This panel is the rest of it: read the membership,\n * add somebody, take somebody off. The list's own settings — its name, and the\n * rule behind a dynamic one — belong to the edit route, because they are\n * facts about the LIST rather than about who is on it.\n *\n * ## It mounts only on a list's own page\n *\n * The audience detail route renders this for the ONE list being read. A\n * membership listener per row would open one per list on the audiences table,\n * on a surface where most visits are to read the list of lists — the\n * read-on-mount shape `emails-console-read-cost.spec.tsx` meters. Nothing here\n * reads anything until a list is opened, and the add form reads nothing until\n * an address is typed.\n *\n * ## Whether somebody may be ADDED is the server's answer, always\n *\n * Every sentence about an address being typed in is `email/list-members-preview`\n * answering from that person's actual record and both suppression lists. This\n * component decides none of it. A screen that decided for itself would be a\n * second copy of the rule on the one surface whose job is to tell the operator\n * the truth about what is about to happen — including, and especially, that\n * the product knows nothing about this person's permission.\n *\n * ## The membership table reads consent through the SEND-TIME reader\n *\n * The `Consent` column answers the same question `performCampaignSend` asks of\n * the same document, so it asks it with the same function —\n * {@link readMarketingBasis}, against the consent group this console is being\n * viewed as. A column that picked the fields out of the row itself is a second\n * reader of a stored shape, and the two only agree until the shape moves.\n *\n * ## Removing is not suppressing, and the screen says so\n *\n * Taking somebody off a list is the operator changing their mind about an\n * audience. It is not the person asking to stop being mailed, it does not stop\n * another list reaching them, and it writes nothing to the suppression list.\n * Those are different acts with different records, and a console that let them\n * blur is a console where \"remove\" is quietly relied on as an unsubscribe.\n */\n\nimport {\n readMarketingBasis,\n type ConsentGroup,\n type DynamicListRule,\n type MarketingConsentRecord,\n} from '@aglyn/aglyn'\nimport { mdiAccountRemoveOutline } from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, deleteDoc, doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useListColumnSort } from '@aglyn/shared-ui-jsx/hooks/use-list-column-sort'\nimport type { ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n LIST_MEMBER_FILTER_HEADERS,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_QUERY,\n} from '../constants/list-queries'\n\nexport interface ListMembersPanelProps {\n hostId: string\n /**\n * The controller this membership is being read AS — the declared group of\n * sites that are one sender, or this site alone.\n *\n * Required and undefaulted, for the reason {@link readMarketingBasis} makes\n * it required: a basis runs to a brand, so a surface that has not said which\n * brand is asking cannot report one. Defaulting to the group of one here\n * would compile and would then answer a declared group's questions with a\n * single site's facts — reporting a sibling's refusal as no record at all,\n * and a pooled grant as another brand's.\n */\n consentGroup: ConsentGroup\n /** `['orgs', orgId]` — the resolved org scope the card already holds. */\n scope: readonly [string, string]\n listId: string\n listName: string\n /**\n * Called after a membership is added or removed.\n *\n * The audience page's subscriber total is a server aggregate, taken once.\n * Left alone it would go on reporting the figure from before the add — a\n * stale total directly above a live table that disagrees with it, which\n * reads as the add having failed. The page re-takes the aggregate on this\n * signal and at no other time, so nothing is re-counted while somebody is\n * only reading.\n */\n onMembershipChanged?: () => void\n /**\n * The list's saved filters, when it has any and its membership is FIXED.\n *\n * Present, the panel offers to find the people they select and add them —\n * the same act as typing addresses, with the search doing the typing. Absent\n * (a live list, or one with no filters saved), the add form is the address\n * box alone: a live list's membership is the sweep's to decide, and offering\n * to add its own matches by hand would produce hand-added copies of rows the\n * sweep already owns.\n */\n findRule?: DynamicListRule | null\n /** The same filters in sentences, so the button says what it will find. */\n ruleSummary?: readonly string[]\n /**\n * Told what the table is filtered to — `null` when it is not — so the\n * card's Export can offer \"the current filter\": the predicates this\n * table's own query is asking, which the export reads with the same query.\n */\n onFilterChange?: (filter: ListMembersFilter | null) => void\n}\n\n/** The member table's filter, as the export dialog offers it. */\nexport interface ListMembersFilter {\n label: string\n value: { filters: ReadonlyArray<{ path: string; op: string; value: unknown }> }\n}\n\n/**\n * One member row, as stored.\n *\n * The consent fields are deliberately NOT enumerated here. They live one level\n * down, in an entry per site under `marketingConsentByHost`, and a row type\n * that named them at the top would be an invitation to read them there — which\n * is exactly what this table did while `enrollListMember` wrote them nested,\n * so every membership on every list reported \"No basis on record\" while the\n * send path mailed the same people on a basis it could see perfectly well. The\n * index signature is what {@link readMarketingBasis} takes; it is the only\n * reader of those fields on this surface.\n */\ninterface MemberRow {\n $id: string\n email?: string\n name?: string\n source?: string\n via?: 'manual' | 'rule'\n addedAt?: { toDate?: () => Date }\n [field: string]: unknown\n}\n\n/** What `email/list-members-preview` answers for one address. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\ninterface Preview {\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** What a filter search found, as `email/list-rule-preview` reports it. */\ninterface FoundPeople extends Preview {\n /** People the filters matched — NOT the length of `emails`. */\n matched: number\n /** `emails` is one batch of a larger match. */\n truncated: boolean\n /** False when the scan hit its budget, so `matched` is itself a floor. */\n complete: boolean\n emails: string[]\n}\n\n/** What one address's outcome was, after the add. */\ninterface AddResult {\n input: string\n email: string | null\n enrolled: boolean\n error?: string\n}\n\n/**\n * How the basis reads on screen, for the group this console is viewed as.\n *\n * ## Read through `readMarketingBasis`, never off the row\n *\n * This picked `marketingConsent`, `marketingConsentBasis` and\n * `marketingConsentAtMs` off the top of the membership document, which is\n * where `enrollListMember` used to put them. They now live in an entry per\n * site under `marketingConsentByHost`, because a basis runs to the brand it\n * was given to rather than to the account that holds the row. Nothing here\n * moved with them, so every top-level read answered `undefined` and every\n * member of every list — including people carrying a perfectly good attested\n * basis — was reported as having no basis on record, on the one screen an\n * operator consults before mailing them. `performCampaignSend` reads the same\n * documents through the function below and mailed them, correctly, at the same\n * time.\n *\n * The fix is not a second field name. It is that the send path's reader is the\n * only reader: a surface that parses the stored shape itself agrees with the\n * sender only until the shape moves, and it moves without anybody thinking to\n * look here.\n *\n * ## Five states, because the model carries five\n *\n * An attestation and an opt-in are not the same fact — one is a person's own\n * decision, the other a claim an account made on their behalf — and a table\n * rendering both as a tick is the conflation `list-members.ts` stores the\n * basis to prevent. The three that are not a grant are not one state either.\n * Absence is not refusal, and neither of them is \"opted in, to somebody else\n * in this account\": that third one is a person the sender deliberately leaves\n * alone, and an operator who cannot see why reads it as the product having\n * lost their subscriber.\n */\ntype ConsentKey =\n | 'opted-out'\n | 'attested'\n | 'opted-in'\n | 'other-site'\n | 'no-site'\n | 'none'\n\n/**\n * A rule-enrolled member's basis as their CONTACT records it, as\n * `email/list-member-consent` answers it.\n *\n * A row a list rule wrote never holds a basis of its own — matching a rule is\n * not an opt-in — so `performCampaignSend` reads those people's contacts, and\n * this column shows what it reads. Fetched, because the browser may not read\n * the org's contacts by address.\n */\nexport interface ContactBasis {\n basis: MarketingConsentRecord['basis']\n assertedBy: MarketingConsentRecord['assertedBy']\n basisAtMs: number | null\n otherGrant: MarketingConsentRecord['otherGrant']\n}\n\n/** The key a member row and the consent route both use for an address. */\nconst memberAddress = (member: MemberRow): string =>\n typeof member.email === 'string' ? member.email.trim().toLowerCase() : ''\n\nexport function consentLabel(\n member: MemberRow,\n group: ConsentGroup,\n fromContact?: ContactBasis | null,\n): { key: ConsentKey; label: string; color: 'default' | 'warning' | 'error' } {\n const own = readMarketingBasis(member as Record<string, unknown>, group)\n // The contact answers only where the row itself is silent and a rule put it\n // there — the same overlay the send applies.\n const record =\n fromContact && member.via === 'rule' && own.basis === 'unrecorded'\n ? fromContact\n : own\n const when = record.basisAtMs\n ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}`\n : ''\n if (record.basis === 'declined') {\n return { key: 'opted-out', label: `Opted out${when}`, color: 'error' }\n }\n if (record.basis === 'granted') {\n /*\n * `assertedBy` rather than the stored basis field, because there are two\n * ways a record says an operator asserted it — a membership's\n * `marketingConsentBasis`, and the provenance object a backfill writes —\n * and the reader has already resolved both into this one answer.\n */\n return record.assertedBy === 'operator'\n ? { key: 'attested', label: `Attested by your team${when}`, color: 'warning' }\n : { key: 'opted-in', label: `Opted in${when}`, color: 'default' }\n }\n /*\n * A grant this site may not use. Reported rather than folded into \"no\n * basis\" because the two are different situations with different answers:\n * nobody has ever asked this person, versus somebody asked them on behalf\n * of another brand in this account and this brand may not borrow it.\n */\n if (record.otherGrant === 'other-host') {\n return { key: 'other-site', label: 'Opted in to another site', color: 'warning' }\n }\n if (record.otherGrant === 'unscoped') {\n return { key: 'no-site', label: 'Opted in — no site recorded', color: 'warning' }\n }\n return { key: 'none', label: 'No basis on record', color: 'default' }\n}\n\n/** How a member got here, by the words the column draws. */\nconst VIA_LABELS: Readonly<Record<'rule' | 'manual', string>> = {\n rule: 'Rule',\n manual: 'Added',\n}\n/** The How filter's choices are picked, so the panel shows a select. */\nconst MEMBER_SELECT_FIELDS = ['via']\n/** The page-sorted header, for its notice (AGL-3680). */\nconst MEMBER_SORT_HEADERS = { consent: 'Consent' }\n\n/** How a row reads `via`: a row that predates the field was added by hand. */\nconst viaOf = (member: MemberRow): 'rule' | 'manual' =>\n member.via === 'rule' ? 'rule' : 'manual'\n\n/** Free text — a paste, a typed address, commas or newlines — to addresses. */\nexport function splitAddresses(value: string): string[] {\n return value\n .split(/[\\s,;]+/)\n .map((entry) => entry.trim())\n // A pasted mail-client line is `Name <a@b.co>`; keep the address part.\n .map((entry) => entry.replace(/^.*<|>.*$/g, ''))\n .filter(Boolean)\n}\n\nexport function ListMembersPanel(props: ListMembersPanelProps) {\n const {\n hostId,\n consentGroup,\n scope,\n listId,\n listName,\n onMembershipChanged,\n findRule,\n ruleSummary,\n } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n /*\n * Ordered by DOCUMENT ID and paged, not capped and re-sorted here.\n *\n * `__name__` rather than `addedAt`, which is the column a reader would\n * assume: `orderBy` FILTERS as well as sorts, so a document missing the\n * field is not in the result at all — and `addedAt` is stamped only when a\n * membership is CREATED, so every row written under the two legacy ids\n * `list-members.ts` still adopts predates it. Ordering on it would drop the\n * oldest members from their own list, silently, which is the failure that\n * turned an audience into a random sample (AGL-2501). The id is the one key\n * every row has, so it stays the default. The Joined header asks for\n * `addedAt` only since the writer stamps it on every row it touches and\n * `backfill-list-member-sort-fields.mjs` on the rest (AGL-3680).\n *\n * Every filter and search word is a predicate on the same query\n * (AGL-3321) — see `LIST_MEMBER_QUERY` — so a page is a page of the\n * matches.\n */\n const gridFilter = useListGridFilter({ selectFields: MEMBER_SELECT_FIELDS })\n /*\n * EVERY HEADER SORTS (AGL-3680). Address, Name, Joined and How order the\n * QUERY — `enrollListMember` stamps each on every row, null where there is\n * none, so no order drops a member — each `alone`: under a filter or the\n * search the list keeps its id order and says so. Consent is read per\n * viewing consent group, so it sorts the page on screen.\n */\n const [askedSort, setAskedSort] = useState<ListQuerySort | null>(null)\n const {\n rows: members,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<MemberRow>({\n collection: collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n declaration: LIST_MEMBER_QUERY,\n request: {\n clauses: gridFilter.clauses,\n search: gridFilter.searchWords,\n sort: askedSort,\n },\n deps: [firestore, scope[0], scope[1], listId],\n idField: '$id',\n })\n /** Rule members' contact bases, by address — see {@link ContactBasis}. */\n const [contactConsent, setContactConsent] = useState<\n Readonly<Record<string, ContactBasis | null>>\n >({})\n const labelFor = useCallback(\n (row: MemberRow) =>\n consentLabel(row, consentGroup, contactConsent[memberAddress(row)]),\n [consentGroup, contactConsent],\n )\n const pageSorts = useMemo(\n () => ({ consent: (row: MemberRow) => labelFor(row).label }),\n [labelFor],\n )\n const columnSort = useListColumnSort<MemberRow>({\n sorts: LIST_MEMBER_QUERY.sorts,\n sort: askedSort,\n onSortChange: setAskedSort,\n orderBy: plan.orderBy,\n rows: members,\n pageSorts,\n headers: MEMBER_SORT_HEADERS,\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n const { onFilterChange } = props\n const filterKey = filtering ? JSON.stringify(plan.filters) : ''\n useEffect(() => {\n if (!onFilterChange) return\n onFilterChange(\n filterKey\n ? {\n label: 'Members matching the table’s filters',\n value: { filters: JSON.parse(filterKey) },\n }\n : null,\n )\n }, [filterKey, onFilterChange])\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: LIST_MEMBER_QUERY.fields,\n headers: LIST_MEMBER_FILTER_HEADERS,\n options: LIST_MEMBER_FILTER_OPTIONS,\n }),\n [plan.refused],\n )\n\n const [addInput, setAddInput] = useState('')\n const [addName, setAddName] = useState('')\n const [preview, setPreview] = useState<Preview | null>(null)\n const [attested, setAttested] = useState(false)\n const [results, setResults] = useState<AddResult[] | null>(null)\n const [busy, setBusy] = useState(false)\n /*\n * The addresses a SEARCH found, when the operator ran one.\n *\n * A second way to fill the same candidate set, not a second way to add. Once\n * this holds addresses, everything below it — the consent readout, the\n * attestation, the Add button — is the code the typed path already used, so\n * there is exactly one place in this component where somebody is put on a\n * list and exactly one gate in front of it.\n */\n const [found, setFound] = useState<FoundPeople | null>(null)\n\n const addresses = found ? found.emails : splitAddresses(addInput)\n\n /*\n * Any edit to the box invalidates the answer.\n *\n * A preview belongs to the exact addresses it was run for. Leaving a stale\n * one on screen would show a count, and an attestation checkbox, for a set\n * the operator has since changed — so the number they stand behind would\n * not be the number that acts. Typing also abandons a search: the two are\n * alternative ways of naming a candidate set, and holding both at once\n * would leave the Add button acting on whichever the code happened to\n * prefer.\n */\n useEffect(() => {\n setPreview(null)\n setAttested(false)\n setResults(null)\n setFound(null)\n }, [addInput])\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n /*\n * The page's rule rows that say nothing about consent themselves, asked of\n * their contacts once per page. Rows already answered are not asked again;\n * a refused or failed read leaves the row's own label, which is the honest\n * fallback — it is what the row records.\n */\n const unansweredRuleRows = members\n .filter(\n (row) =>\n row.via === 'rule' &&\n consentLabel(row, consentGroup).key === 'none' &&\n !(memberAddress(row) in contactConsent),\n )\n .map(memberAddress)\n .filter(Boolean)\n const unansweredKey = unansweredRuleRows.join('\\n')\n useEffect(() => {\n if (!unansweredKey || !user) return\n let current = true\n const asked = unansweredKey.split('\\n')\n void post('list-member-consent', { emails: asked })\n .then(({ ok, payload }) => {\n if (!current || !ok) return\n const answered = (payload?.consent ?? {}) as Record<string, ContactBasis>\n // Every address asked is settled — `null` where the route had nothing\n // to add — so a page is asked about once.\n const settled: Record<string, ContactBasis | null> = {}\n for (const email of asked) settled[email] = answered[email] ?? null\n setContactConsent((before) => ({ ...before, ...settled }))\n })\n .catch(() => undefined)\n return () => {\n current = false\n }\n }, [unansweredKey, user, post])\n\n const handlePreview = useCallback(async () => {\n if (busy || !addresses.length) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-preview', {\n emails: addresses,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The addresses could not be checked.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as Preview)\n } catch {\n enqueueSnackbar('The addresses could not be checked.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, addresses, post, enqueueSnackbar])\n\n const handleFind = useCallback(async () => {\n if (busy || !findRule) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-rule-preview', {\n rule: findRule,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The audience could not be worked out.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n /*\n * The verdicts come back from the SAME resolution the typed path runs,\n * so what lands in `preview` here is the same object the consent\n * readout below already knows how to draw. A search that returned its\n * own shaped summary would be a second consent readout, and the second\n * one is the one nobody reviews.\n */\n setFound(payload as FoundPeople)\n setPreview(payload as Preview)\n setAttested(false)\n setResults(null)\n if (!Number(payload?.matched ?? 0)) {\n enqueueSnackbar('These filters match nobody', {\n variant: 'info',\n persist: false,\n })\n }\n } catch {\n enqueueSnackbar('The audience could not be worked out.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, findRule, post, enqueueSnackbar])\n\n const handleAdd = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-add', {\n emails: addresses,\n ...(addresses.length === 1 && addName.trim()\n ? { name: addName.trim() }\n : {}),\n // The operator's assertion, not a basis they named: the server\n // derives a pass-through from each person's own record, and this flag\n // can only ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'Nobody was added to the list',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const added = Number(payload?.added ?? 0)\n setResults((payload?.results ?? []) as AddResult[])\n setPreview(null)\n if (added) {\n setAddInput('')\n setAddName('')\n setFound(null)\n onMembershipChanged?.()\n }\n enqueueSnackbar(\n added === 1 ? 'One person added' : `${added} people added`,\n { variant: added ? 'success' : 'warning', persist: false },\n )\n } catch {\n enqueueSnackbar('Nobody was added to the list', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [\n busy,\n preview,\n addresses,\n addName,\n attested,\n post,\n enqueueSnackbar,\n onMembershipChanged,\n ])\n\n const handleRemove = useCallback(\n async (member: MemberRow) => {\n const accepted = await confirm({\n title: 'Remove from this list?',\n description:\n `${member.email ?? 'This person'} stops being part of \"${listName}\" ` +\n 'and campaigns to this list stop reaching them. This is NOT an ' +\n 'unsubscribe: it does not add them to your suppression list, and ' +\n 'any other list they are on still reaches them. If they asked to ' +\n 'stop hearing from you, suppress the address instead.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone would make this always proceed (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n try {\n await deleteDoc(\n doc(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n listId,\n 'members',\n member.$id,\n ),\n )\n onMembershipChanged?.()\n enqueueSnackbar('Removed from the list', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('They were not removed', { variant: 'error' })\n }\n },\n [\n confirm,\n listName,\n firestore,\n scope,\n listId,\n enqueueSnackbar,\n onMembershipChanged,\n ],\n )\n\n const enrollable = preview\n ? preview.optedIn + preview.needAttestation\n : 0\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.email ?? '—',\n },\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 160,\n valueGetter: (_value, row) => row.name ?? '—',\n },\n {\n field: 'addedAt',\n headerName: 'Joined',\n width: 120,\n // Sorted on the instant, drawn as a local date.\n valueGetter: (_value, row) => row.addedAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row }) =>\n row.addedAt?.toDate ? row.addedAt.toDate().toLocaleDateString() : '—',\n },\n {\n field: 'via',\n headerName: 'How',\n width: 200,\n valueGetter: (_value, row) => viaOf(row),\n /*\n `via` says whether a rule put them here or a person did, which is what\n decides whether they LEAVE on their own: the materializer reconciles its\n own rows away and never touches a manual one. `source` is the finer\n provenance underneath it.\n */\n renderCell: ({ row }) => (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Chip size=\"small\" variant=\"outlined\" label={VIA_LABELS[viaOf(row)]} />\n {row.source ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.source}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'consent',\n headerName: 'Consent',\n width: 190,\n valueGetter: (_value, row) => labelFor(row).label,\n renderCell: ({ row }) => {\n const consent = labelFor(row)\n return (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={consent.color}\n label={consent.label}\n />\n )\n },\n },\n /*\n A SUBSCRIBER HAS NO PAGE of their own here, so the row opens nothing —\n but taking somebody off an audience is the one act performed on one, and\n it belongs in the trailing cluster with every other list's actions rather\n than as a red text button sitting in the row.\n */\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.email ?? row.$id)}\n items={[\n {\n key: 'remove',\n label: 'Remove from this list',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <Stack spacing={2} sx={{ py: 2 }}>\n {/*\n FINDING people, for a list whose membership is fixed.\n The filters are the same ones a live list is swept by; here they run\n once, on request, and what they find is offered for adding rather than\n enrolled. The count and the consent readout arrive together, because\n the second is the thing that decides how much of the first can act.\n */}\n {findRule ? (\n <Stack\n spacing={1}\n sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Find people'}\n </Typography>\n {(ruleSummary ?? []).map((clause) => (\n <Typography key={clause} variant=\"body2\">\n {clause}\n </Typography>\n ))}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleFind()}\n >\n {busy ? 'Searching…' : 'Find matching people'}\n </Button>\n </Box>\n {found ? (\n <Alert severity={found.matched ? 'info' : 'warning'}>\n {(found.matched === 1\n ? '1 person matches'\n : `${found.matched.toLocaleString()} people match`) +\n (found.truncated\n ? ` — this batch covers the first ${found.emails.length}. ` +\n 'Add them, then search again for the rest.'\n : '') +\n (found.complete\n ? ''\n : ' The search stopped at its read budget, so this is at ' +\n 'least that many rather than exactly.')}\n </Alert>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whoever you add stays on this list. It does not keep growing — ' +\n 'change the membership to live on the edit page if you want ' +\n 'new matches enrolled automatically.'}\n </Typography>\n </Stack>\n ) : null}\n\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Add someone by typing their address, or paste a column of them. ' +\n 'Every address is checked against their consent record and both ' +\n 'suppression lists before anyone is added.'}\n </Typography>\n\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"Email addresses\"\n placeholder=\"someone@example.com\"\n multiline\n minRows={1}\n maxRows={6}\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n helperText={\n addresses.length > 1\n ? `${addresses.length} addresses`\n : 'One address, or paste many'\n }\n sx={{ flexGrow: 1, minWidth: 260 }}\n />\n {addresses.length > 1 ? null : (\n <TextField\n size=\"small\"\n label=\"Name\"\n value={addName}\n onChange={(event) => setAddName(event.target.value)}\n sx={{ minWidth: 180 }}\n />\n )}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy || !addresses.length || Boolean(preview)}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check'}\n </Button>\n </Box>\n </Stack>\n\n {/*\n The count, in front of the operator, BEFORE the attestation control\n exists. One assertion covers the batch, so the number it covers has to\n be on screen when it is given — an attestation offered above an\n unexamined paste is a signature on a blank page.\n */}\n {preview ? (\n <Stack spacing={1}>\n <Alert severity={enrollable ? 'info' : 'warning'}>\n {[\n preview.optedIn\n ? `${preview.optedIn} already opted in`\n : '',\n preview.needAttestation\n ? `${preview.needAttestation} with no opt-in on record`\n : '',\n preview.refused\n ? `${preview.refused} cannot be added at all`\n : '',\n ]\n .filter(Boolean)\n .join(' · ')}\n </Alert>\n {/*\n The HARD refusals only. An address that merely needs the\n attestation is not a refusal — listing it here would put the very\n people the checkbox below is about under a heading that says they\n cannot be added.\n */}\n {preview.verdicts\n .filter((verdict) => verdict.refusal)\n .map((verdict) => (\n <Typography\n key={verdict.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${verdict.input} — ${verdict.summary}`}\n </Typography>\n ))}\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => setAttested(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {`I have permission to send marketing email to the ` +\n `${preview.needAttestation} ` +\n `${preview.needAttestation === 1 ? 'person' : 'people'} ` +\n 'above with no opt-in on record. This is recorded against ' +\n 'my account, with the date.'}\n </Typography>\n }\n />\n ) : null}\n <Box>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={\n busy ||\n !enrollable ||\n (preview.needAttestation > 0 && !attested)\n }\n onClick={() => void handleAdd()}\n >\n {busy ? 'Adding…' : `Add ${enrollable}`}\n </Button>\n </Box>\n </Stack>\n ) : null}\n\n {/*\n What actually happened, per address. A batch where one address was\n suppressed and forty were fine is not a failure, and a bare count would\n leave the operator to work out which of their lines is missing.\n */}\n {results?.length ? (\n <Stack spacing={0.5}>\n {results\n .filter((result) => !result.enrolled)\n .map((result) => (\n <Typography\n key={result.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${result.input} — not added. ${result.error ?? ''}`}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {members.length === 0 && !hasMore && !filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is on this list yet.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={LIST_MEMBER_QUERY.fields}\n headers={LIST_MEMBER_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={LIST_MEMBER_FILTER_OPTIONS}\n />\n <ListQueryNotices\n refused={refusals}\n notices={[...plan.notices, ...columnSort.notices]}\n />\n <ListTable\n aria-label={`Members of ${listName}`}\n rows={columnSort.rows}\n columnSort={columnSort}\n columns={listFilterGridColumns(\n columns,\n LIST_MEMBER_QUERY.fields,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel, the search and the headers go to the query (Consent\n // sorts the page, and says so); the grid itself sorts nothing.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n noRowsLabel=\"No members match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={members.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n\n {/*\n What the column MEANS, and what to do about the one state an operator\n can act on. \"No basis on record\" is a fact about this site's records,\n not a refusal and not a dead end — the remedy is the add form above,\n which merges onto the membership that is already here rather than\n creating a second one. Whether a basis-less member is actually mailed\n is the org's consent policy to decide and is deliberately not claimed\n here: this surface does not hold that setting, and a caption that\n guessed it would be wrong for every org on the other mode.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'\"No basis on record\" means nobody has recorded this person\\'s ' +\n 'permission for this site — it is not a refusal. To record one, put ' +\n 'their address in the box above and state that you have their ' +\n 'permission; the statement is kept against your account, with the ' +\n 'date.'}\n </Typography>\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Removing someone from a list is not an unsubscribe. It does not add ' +\n 'them to your suppression list, and it does not stop any other list ' +\n 'reaching them. If somebody asked you to stop, suppress the address.'}\n </Typography>\n </Stack>\n )\n}\nListMembersPanel.displayName = 'ListMembersPanel'\n\nexport default ListMembersPanel\n"],"names":["readMarketingBasis","mdiAccountRemoveOutline","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","Alert","Box","Button","Checkbox","Chip","FormControlLabel","Stack","TextField","Typography","collection","deleteDoc","doc","useCallback","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","useListColumnSort","authorizedFetch","LIST_MEMBER_FILTER_HEADERS","LIST_MEMBER_FILTER_OPTIONS","LIST_MEMBER_QUERY","memberAddress","member","email","trim","toLowerCase","consentLabel","group","fromContact","own","record","via","basis","when","basisAtMs","Date","toLocaleDateString","key","label","color","assertedBy","otherGrant","VIA_LABELS","rule","manual","MEMBER_SELECT_FIELDS","MEMBER_SORT_HEADERS","consent","viaOf","splitAddresses","value","split","map","entry","replace","filter","Boolean","ListMembersPanel","props","hostId","consentGroup","scope","listId","listName","onMembershipChanged","findRule","ruleSummary","firestore","data","user","enqueueSnackbar","confirm","gridFilter","selectFields","askedSort","setAskedSort","rows","members","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","sort","deps","idField","contactConsent","setContactConsent","labelFor","row","pageSorts","columnSort","sorts","onSortChange","orderBy","headers","filtering","length","some","word","onFilterChange","filterKey","JSON","stringify","filters","parse","refusals","refused","fields","options","addInput","setAddInput","addName","setAddName","preview","setPreview","attested","setAttested","results","setResults","busy","setBusy","found","setFound","addresses","emails","post","route","body","response","method","payload","json","catch","ok","unansweredRuleRows","unansweredKey","join","current","asked","then","answered","settled","before","undefined","handlePreview","error","variant","allowDuplicate","handleFind","Number","matched","persist","handleAdd","name","attestConsent","added","handleRemove","accepted","title","description","confirmationText","confirmationButtonProps","$id","console","enrollable","optedIn","needAttestation","columns","field","headerName","flex","minWidth","valueGetter","_value","width","addedAt","toDate","getTime","renderCell","direction","spacing","sx","alignItems","size","source","noWrap","String","items","icon","path","destructive","onClick","py","border","borderColor","borderRadius","p","clause","disabled","severity","toLocaleString","truncated","complete","useFlexGap","flexWrap","placeholder","multiline","minRows","maxRows","onChange","event","target","helperText","flexGrow","verdicts","verdict","refusal","input","summary","control","checked","result","enrolled","setClauses","notices","aria-label","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","noRowsLabel","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
@@ -35,6 +35,30 @@ export { LIST_MEMBER_BATCH_MAX, resolveAddresses, resolveListContext, type Addre
35
35
  * the box was ticked would not be a count anybody could stand behind.
36
36
  */
37
37
  export declare const emailListMembersPreviewHandler: PluginApiHandler;
38
+ /** The most member rows one consent read may name — one table page and then some. */
39
+ export declare const LIST_MEMBER_CONSENT_MAX = 100;
40
+ /**
41
+ * `POST email/list-member-consent` — the basis a send will act on, for the
42
+ * rows of a list a RULE enrolled.
43
+ *
44
+ * Body: `{ hostId, listId, emails[] }`. Answers `{ consent: { [email]:
45
+ * { basis, assertedBy, basisAtMs, otherGrant } } }` for each named address
46
+ * that is a `via: 'rule'` member of the list with no basis on its own row;
47
+ * every other address is left out, and the table keeps reading those rows
48
+ * itself.
49
+ *
50
+ * A rule-enrolled row never holds a basis — matching a rule is not an opt-in
51
+ * — so `performCampaignSend` reads those people's contacts instead, through
52
+ * `withContactConsentForRuleMembers`. The column calls this so it shows the
53
+ * same answer; the browser cannot make the read itself, because the rules
54
+ * put the org's contacts behind org-wide membership and a `visibleTo` scope
55
+ * that an address lookup cannot be combined with.
56
+ *
57
+ * The member rows are re-read here rather than taken from the request, so a
58
+ * caller cannot get a contact's consent read for an address that is not a
59
+ * rule member of this list. Reads only, behind the same gate as the preview.
60
+ */
61
+ export declare const emailListMemberConsentHandler: PluginApiHandler;
38
62
  /**
39
63
  * `POST email/list-members-add` — put these addresses on the list.
40
64
  *
@@ -73,8 +73,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
73
73
  import { registerEmailSuppressionsApi } from "./server-suppressions.js";
74
74
  import { registerEmailListImportApi } from "./server-list-import.js";
75
75
  import { LIST_MEMBER_BATCH_MAX, readAddresses, resolveAddresses, resolveListContext } from "./server-list-gate.js";
76
- import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, dynamicListRuleIsEmpty, normalizeDynamicListRule, readMarketingBasis, registerPluginApiRoute } from "@aglyn/aglyn/server";
77
- import { collectDynamicListCandidates, enrollListMember, firebaseAdmin } from "@aglyn/tenant-data-admin";
76
+ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, dynamicListRuleIsEmpty, normalizeContactEmail, normalizeDynamicListRule, readMarketingBasis, registerPluginApiRoute } from "@aglyn/aglyn/server";
77
+ import { collectDynamicListCandidates, consentGroupForSite, enrollListMember, firebaseAdmin, orgDataCollectionForHost } from "@aglyn/tenant-data-admin";
78
+ import { CONTACT_LOOKUP_CHUNK, withContactConsentForRuleMembers } from "@aglyn/tenant-data-admin/server/consent-by-address";
78
79
  import { registerSubscriptionTopicIndex } from "./server-topic-index.js";
79
80
  /** `source` stamped on every membership these routes write. */ export const CONSOLE_ADD_SOURCE = 'console:list-add';
80
81
  /*
@@ -141,6 +142,96 @@ import { registerSubscriptionTopicIndex } from "./server-topic-index.js";
141
142
  });
142
143
  }
143
144
  };
145
+ /** The most member rows one consent read may name — one table page and then some. */ export const LIST_MEMBER_CONSENT_MAX = 100;
146
+ /**
147
+ * `POST email/list-member-consent` — the basis a send will act on, for the
148
+ * rows of a list a RULE enrolled.
149
+ *
150
+ * Body: `{ hostId, listId, emails[] }`. Answers `{ consent: { [email]:
151
+ * { basis, assertedBy, basisAtMs, otherGrant } } }` for each named address
152
+ * that is a `via: 'rule'` member of the list with no basis on its own row;
153
+ * every other address is left out, and the table keeps reading those rows
154
+ * itself.
155
+ *
156
+ * A rule-enrolled row never holds a basis — matching a rule is not an opt-in
157
+ * — so `performCampaignSend` reads those people's contacts instead, through
158
+ * `withContactConsentForRuleMembers`. The column calls this so it shows the
159
+ * same answer; the browser cannot make the read itself, because the rules
160
+ * put the org's contacts behind org-wide membership and a `visibleTo` scope
161
+ * that an address lookup cannot be combined with.
162
+ *
163
+ * The member rows are re-read here rather than taken from the request, so a
164
+ * caller cannot get a contact's consent read for an address that is not a
165
+ * rule member of this list. Reads only, behind the same gate as the preview.
166
+ */ export const emailListMemberConsentHandler = async (req, res)=>{
167
+ var _req_body;
168
+ if (req.method !== 'POST') {
169
+ return res.status(405).json({
170
+ error: 'Method not allowed'
171
+ });
172
+ }
173
+ const raw = Array.isArray((_req_body = req.body) == null ? void 0 : _req_body.emails) ? req.body.emails : [];
174
+ const emails = [
175
+ ...new Set(raw.map((value)=>normalizeContactEmail(value)).filter((value)=>Boolean(value)))
176
+ ];
177
+ if (emails.length > LIST_MEMBER_CONSENT_MAX) {
178
+ return res.status(400).json({
179
+ error: `At most ${LIST_MEMBER_CONSENT_MAX} addresses per request.`
180
+ });
181
+ }
182
+ try {
183
+ const context = await resolveListContext(req);
184
+ if (context.ok === false) {
185
+ return res.status(context.status).json(context.body);
186
+ }
187
+ if (!emails.length) return res.status(200).json({
188
+ consent: {}
189
+ });
190
+ const group = await consentGroupForSite(context.hostId);
191
+ const records = new Map();
192
+ const ruleMembers = new Set();
193
+ const members = context.listRef.collection('members');
194
+ for(let at = 0; at < emails.length; at += CONTACT_LOOKUP_CHUNK){
195
+ const chunk = emails.slice(at, at + CONTACT_LOOKUP_CHUNK);
196
+ const snapshot = await members.where('email', 'in', chunk).get();
197
+ for (const doc of snapshot.docs){
198
+ const email = normalizeContactEmail(doc.get('email'));
199
+ if (!email || doc.get('via') !== 'rule') continue;
200
+ ruleMembers.add(email);
201
+ records.set(email, readMarketingBasis(doc.data(), group));
202
+ }
203
+ }
204
+ // Only the rows the overlay will fill are answered.
205
+ const unrecorded = new Set([
206
+ ...ruleMembers
207
+ ].filter((email)=>{
208
+ var _records_get;
209
+ return ((_records_get = records.get(email)) == null ? void 0 : _records_get.basis) === 'unrecorded';
210
+ }));
211
+ await withContactConsentForRuleMembers(orgDataCollectionForHost(context.hostId, 'contacts'), group, records, unrecorded);
212
+ const consent = {};
213
+ for (const email of unrecorded){
214
+ const record = records.get(email);
215
+ if (!record) continue;
216
+ // Named fields: the record also carries provenance that is not the
217
+ // browser's (see the preview route above).
218
+ consent[email] = {
219
+ basis: record.basis,
220
+ assertedBy: record.assertedBy,
221
+ basisAtMs: record.basisAtMs,
222
+ otherGrant: record.otherGrant
223
+ };
224
+ }
225
+ return res.status(200).json({
226
+ consent
227
+ });
228
+ } catch (error) {
229
+ console.error('[email] list member consent read failed', error);
230
+ return res.status(500).json({
231
+ error: 'Consent could not be read.'
232
+ });
233
+ }
234
+ };
144
235
  /**
145
236
  * `POST email/list-members-add` — put these addresses on the list.
146
237
  *
@@ -413,6 +504,7 @@ import { registerSubscriptionTopicIndex } from "./server-topic-index.js";
413
504
  registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler);
414
505
  registerPluginApiRoute('email/list-members-preview', emailListMembersPreviewHandler);
415
506
  registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler);
507
+ registerPluginApiRoute('email/list-member-consent', emailListMemberConsentHandler);
416
508
  // The importer runs on the transfer framework (its resource registers from
417
509
  // the console declarations), and its permission step reads what the dry
418
510
  // run found through this one route — behind `resolveListContext`, the gate
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-console.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 * Putting somebody on an email list from the Emails console.\n *\n * The audience card could create a list and delete a list and nothing else:\n * no way to see who was on one, no way to add anybody, no way to take anybody\n * off. Every member document in production arrived from a capture surface —\n * the newsletter handler, the workflow `enrollList` step, the dynamic-list\n * materializer, the Inbox assignment — so the one act a merchant most expects\n * of a list, \"add this person\", was the one act the product refused.\n *\n * ## Why this is a route and not a client write\n *\n * Rules put `orgs/{orgId}/lists/{listId}/members` behind org-wide membership,\n * and until now that block allowed a client CREATE. Nothing used it, and it\n * was the whole feature waiting to be built wrong: a browser that can write a\n * member document can write `marketingConsent: true` beside an address it has\n * never checked, which is a consent record minted by pressing a button. The\n * rules now deny client create and update on that collection, and this route\n * is the writer — through `enrollListMember`, which owns the document id, and\n * through the shared `enrollment-basis`, which owns the basis.\n *\n * ## The same policy the Inbox uses, not a second one\n *\n * `assignmentBasis` and `assignmentReadout` are imported from the framework,\n * where they moved when this became their second caller. There is no third\n * consent basis and no console-only override: a stored `declined` refuses\n * here exactly as it refuses there, an attestation is recorded with the\n * account that made it, and a stored opt-in is carried across with the date\n * the PERSON set rather than the date somebody pressed Add.\n *\n * ## Reads only, until the operator has seen the count\n *\n * `email/list-members-preview` writes nothing. It answers, per address, what\n * would happen and why, so the attestation the operator gives on the second\n * call is given with the numbers in front of them. Both routes run the SAME\n * resolution over the SAME inputs — `resolveAddresses` — so the summary they\n * were shown is the summary that acts.\n *\n * ## Finding people is a read, and it uses the same gate\n *\n * `email/list-rule-preview` answers \"who do these filters select\" without\n * writing anything, so a fixed list can be filled from a search rather than\n * from somebody typing addresses one at a time. It runs the addresses it finds\n * through `resolveAddresses` as well — a bulk path that reached the membership\n * without the suppression check and the attestation would be a way to enroll\n * exactly the people the single-address path refuses.\n *\n * ## The gate itself is `server-list-gate.ts`\n *\n * Who may change a list's membership, and what is true about each address, are\n * asked by these two routes and by the file importer (the list-member transfer\n * resource, `transfer/list-members.server.ts`). They live in a module of their\n * own so the importer shares this file's answer rather than importing this\n * file — which would be a cycle, and a cycle whose module-level constants\n * would evaluate in whichever order the loader happened to reach them.\n */\n\nimport { registerEmailDesignDraftWriter } from './server-email-drafts'\nimport { registerEmailSuppressionsApi } from './server-suppressions'\nimport { registerEmailListImportApi } from './server-list-import'\nimport {\n LIST_MEMBER_BATCH_MAX,\n readAddresses,\n resolveAddresses,\n resolveListContext,\n} from './server-list-gate'\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n dynamicListRuleIsEmpty,\n normalizeDynamicListRule,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n collectDynamicListCandidates,\n enrollListMember,\n firebaseAdmin,\n} from '@aglyn/tenant-data-admin'\nimport { registerSubscriptionTopicIndex } from './server-topic-index'\n\n/** `source` stamped on every membership these routes write. */\nexport const CONSOLE_ADD_SOURCE = 'console:list-add'\n\n/*\n * Re-exported, not redefined. `@aglyn/plugins-email/server` has published\n * these names since the add path shipped, and the console panel and its specs\n * import them from there; moving the gate must not move the plugin's public\n * surface with it.\n */\nexport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type AddressResolution,\n type AddressVerdict,\n type ListContext,\n type ResolvedBatch,\n} from './server-list-gate'\n\n\n/**\n * `POST email/list-members-preview` — what adding these addresses would do.\n *\n * Reads only, and reached by an explicit act — typing an address or pasting a\n * column — never on mount. It exists because the answer needs three things\n * the browser cannot have: the org's contacts (rules put them behind org-wide\n * membership, which the acting console session may not hold) and both\n * suppression lists. Computing any of it client-side would be a second copy\n * of the rule, on the surface whose whole job is to tell the operator the\n * truth about what is about to happen.\n *\n * It takes no attestation and has nowhere to put one. The preview answers\n * what is TRUE about each person, which is the input to the operator's\n * decision rather than a function of it — a preview whose numbers moved as\n * the box was ticked would not be a count anybody could stand behind.\n */\nexport const emailListMembersPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const { verdicts, optedIn, needAttestation, refused } =\n await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n /*\n * Named fields rather than a spread of the resolution.\n *\n * The resolution carries the consent RECORDS it was computed from, for the\n * write path's use — raw stored basis, provenance, the account behind an\n * operator assertion. None of that is the browser's, and a spread would\n * put all of it on the wire the moment a field was added to the internal\n * shape. What the surface needs is the verdicts and the counts.\n */\n return res.status(200).json({\n listName: context.listName,\n verdicts,\n optedIn,\n needAttestation,\n refused,\n })\n } catch (error) {\n console.error('[email] list member preview failed', error)\n return res.status(500).json({ error: 'The addresses could not be checked.' })\n }\n}\n\n/**\n * `POST email/list-members-add` — put these addresses on the list.\n *\n * Body: `{ hostId, listId, email | emails[], name?, attestConsent? }`.\n * `attestConsent` is the operator STATING that they have these people's\n * permission; it is not a way to name a basis, because the pass-through basis\n * is derived server-side from each person's own record.\n *\n * ## One attestation, for the count the operator was shown\n *\n * The batch carries a single assertion because it is a single act: an\n * operator pasting a column is making one claim about where that column came\n * from. What makes that safe is that the claim is applied per address by the\n * same function the preview ran, so it reaches only the addresses that\n * actually need it — an address with a stored opt-in keeps its own basis and\n * its own date, and an address nothing can enroll is refused with the\n * attestation on the table.\n *\n * ## Partial success is the honest answer\n *\n * A batch where one address is suppressed and forty are fine is not a failed\n * request. Every address comes back with what happened to it, so 200 here\n * means \"the request was processed\", never \"everybody was added\" — the caller\n * reads the per-address verdicts, which is why they are returned rather than\n * a count.\n */\nexport const emailListMembersAddHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n const attested = req.body?.attestConsent === true\n const name = String(req.body?.name ?? '').trim()\n\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const nowMs = Date.now()\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n\n const results = []\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: verdict.refusal,\n error: verdict.summary,\n })\n continue\n }\n /*\n * The ONE place a basis is decided, and the only place the attestation\n * is consulted.\n *\n * `resolveAddresses` has already answered the questions that are facts\n * about the person; this answers the question that is about the\n * operator, per address, from the consent record that resolution read.\n * `no-basis` — an address with nothing on record and nobody asserting\n * anything — is refused HERE and only here.\n */\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: decision.refusal,\n error: ASSIGNMENT_REFUSAL_MESSAGES[decision.refusal],\n })\n continue\n }\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(name && requested.emails.length === 1 ? { name } : {}),\n source: CONSOLE_ADD_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when a person stops matching, and a decision somebody\n // made by hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in'\n ? { grantEntries: resolution.grants.get(verdict.email) ?? {} }\n : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds\n * the row, so it is the backstop for every enrollment route; reaching\n * it here means the two records disagree, and the refusal wins.\n */\n const reason: AssignmentRefusal =\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address'\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n continue\n }\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: true,\n memberId: enrollment.memberId,\n created: enrollment.created,\n basis: decision.basis,\n })\n }\n\n return res.status(200).json({\n listName: context.listName,\n added: results.filter((result) => result.enrolled).length,\n results,\n })\n } catch (error) {\n console.error('[email] list member add failed', error)\n return res.status(500).json({ error: 'The addresses could not be added.' })\n }\n}\n\n/**\n * WHO A SET OF FILTERS FINDS, and what would happen if you added them.\n *\n * ## The gap it fills\n *\n * The filters behind a list could only ever be MATERIALIZED — the sweep wrote\n * the matching people straight into the membership. A merchant could not ask\n * \"who is this\" without committing to it, and a fixed list could not use the\n * filters at all: its only way to gain a member was somebody typing or pasting\n * an address. This answers the question without writing anything.\n *\n * ## It is the same consent gate, not a second one\n *\n * The addresses the scan finds are put through {@link resolveAddresses} — the\n * exact function `email/list-members-preview` uses — so a suppressed address\n * is reported as refused here, and somebody with no opt-in on record is\n * reported as needing an attestation here, before any of them is offered for\n * adding. That is deliberate and load-bearing: a bulk path that skipped the\n * check would be a way to fill a list with people the one-at-a-time path\n * refuses, which is the defect class this product already has a register entry\n * for. The ADD still goes through `email/list-members-add`, which re-runs\n * every check server-side, so this preview is an honest readout rather than a\n * permission.\n *\n * ## What it will not do\n *\n * It writes nothing, it enrolls nobody, and it compares no count against any\n * limit. The batch cap below bounds how many addresses it hands back for a\n * single add — the scan itself still reports how many people it MATCHED, so a\n * merchant is told the audience is larger than one batch rather than shown a\n * truncated number as if it were the whole.\n */\nexport const emailListRulePreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const rule = normalizeDynamicListRule(req.body?.rule)\n if (dynamicListRuleIsEmpty(rule)) {\n /*\n * Not an error, and not an empty result either. A rule with no source\n * matches nobody by construction, and reporting that as \"0 people\" is\n * indistinguishable from a rule that ran and found none — which is the\n * confusion the `empty` flag exists to prevent everywhere else.\n */\n return res.status(200).json({\n matched: 0,\n truncated: false,\n complete: true,\n empty: true,\n emails: [],\n verdicts: [],\n optedIn: 0,\n needAttestation: 0,\n refused: 0,\n })\n }\n const scan = await collectDynamicListCandidates({\n hostId: context.hostId,\n rule,\n })\n /*\n * The addresses, in the scan's own order, capped at what one add can\n * carry. `matched` is reported separately and is NOT this length: a\n * merchant looking at a 400-person audience must be told it is 400 even\n * when the button in front of them adds 100.\n */\n const emails = scan.candidates\n .map((candidate) => candidate.email)\n .filter(Boolean)\n const batch = emails.slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: batch,\n })\n return res.status(200).json({\n listName: context.listName,\n matched: emails.length,\n /*\n * Two different reasons a readout can be short of the truth, reported\n * apart: `truncated` is this batch being smaller than the match, and\n * `complete: false` is the SCAN having run out of budget, so `matched`\n * is itself a floor.\n */\n truncated: emails.length > batch.length,\n complete: scan.complete,\n empty: false,\n emails: batch,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list rule preview failed', error)\n return res\n .status(500)\n .json({ error: 'The audience could not be worked out.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`, so each is counted against the console\n * dispatcher's per-subject budget — the ceiling that bounds a surface which\n * puts a person into a marketing audience, and which scans a whole contact\n * silo to work out who that would be.\n *\n * The limiter these are counted by is the CONSOLE one,\n * `consoleApiRateLimitRefusal`. The visitor limiter is a different limiter on\n * a different dispatcher: `visitorWriteRateLimitRefusal` is installed only in\n * `apps/tenant`, and no request to these routes has ever passed through it.\n * The two share a counter and an exemption list and nothing else — the console\n * key is the authenticated subject, because on an authenticated surface a\n * per-(site, IP) bucket both isolates nothing the identity has not already\n * isolated and hands one operator a fresh budget per site.\n */\nexport function registerEmailConsoleApi(): void {\n // The email design writer another plugin reaches through the core's\n // resource-drafts seam (AGL-2912). Registered here alone: the AI jobs that\n // write through it run only on the console, inline and on the beat\n // (AGL-3026).\n registerEmailDesignDraftWriter()\n // The org's topic catalog, as the tenant surface publishes it: the console\n // runs sends under a stream too.\n registerSubscriptionTopicIndex(() => firebaseAdmin.app().firestore())\n registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler)\n registerPluginApiRoute(\n 'email/list-members-preview',\n emailListMembersPreviewHandler,\n )\n registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler)\n // The importer runs on the transfer framework (its resource registers from\n // the console declarations), and its permission step reads what the dry\n // run found through this one route — behind `resolveListContext`, the gate\n // the import's own writes reach through `resolveAddresses` too.\n registerEmailListImportApi()\n // Suppressions live in their own module: they are a per-SITE list gated on\n // the site role, where list membership is an ORG audience gated on org-wide\n // access, and one file holding both gates is one file for the wrong one to\n // be copied out of.\n registerEmailSuppressionsApi()\n}\n"],"names":["registerEmailDesignDraftWriter","registerEmailSuppressionsApi","registerEmailListImportApi","LIST_MEMBER_BATCH_MAX","readAddresses","resolveAddresses","resolveListContext","ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","dynamicListRuleIsEmpty","normalizeDynamicListRule","readMarketingBasis","registerPluginApiRoute","collectDynamicListCandidates","enrollListMember","firebaseAdmin","registerSubscriptionTopicIndex","CONSOLE_ADD_SOURCE","emailListMembersPreviewHandler","req","res","method","status","json","error","requested","context","ok","body","verdicts","optedIn","needAttestation","refused","hostId","inputs","emails","listName","console","emailListMembersAddHandler","attested","attestConsent","name","String","trim","nowMs","Date","now","resolution","results","verdict","refusal","email","push","input","enrolled","reason","summary","decision","stored","get","group","actingUid","uid","enrollment","listRef","length","source","via","consent","basis","grantEntries","grants","memberId","created","added","filter","result","emailListRulePreviewHandler","rule","matched","truncated","complete","empty","scan","candidates","map","candidate","Boolean","batch","slice","registerEmailConsoleApi","app","firestore"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-console.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 * Putting somebody on an email list from the Emails console.\n *\n * The audience card could create a list and delete a list and nothing else:\n * no way to see who was on one, no way to add anybody, no way to take anybody\n * off. Every member document in production arrived from a capture surface —\n * the newsletter handler, the workflow `enrollList` step, the dynamic-list\n * materializer, the Inbox assignment — so the one act a merchant most expects\n * of a list, \"add this person\", was the one act the product refused.\n *\n * ## Why this is a route and not a client write\n *\n * Rules put `orgs/{orgId}/lists/{listId}/members` behind org-wide membership,\n * and until now that block allowed a client CREATE. Nothing used it, and it\n * was the whole feature waiting to be built wrong: a browser that can write a\n * member document can write `marketingConsent: true` beside an address it has\n * never checked, which is a consent record minted by pressing a button. The\n * rules now deny client create and update on that collection, and this route\n * is the writer — through `enrollListMember`, which owns the document id, and\n * through the shared `enrollment-basis`, which owns the basis.\n *\n * ## The same policy the Inbox uses, not a second one\n *\n * `assignmentBasis` and `assignmentReadout` are imported from the framework,\n * where they moved when this became their second caller. There is no third\n * consent basis and no console-only override: a stored `declined` refuses\n * here exactly as it refuses there, an attestation is recorded with the\n * account that made it, and a stored opt-in is carried across with the date\n * the PERSON set rather than the date somebody pressed Add.\n *\n * ## Reads only, until the operator has seen the count\n *\n * `email/list-members-preview` writes nothing. It answers, per address, what\n * would happen and why, so the attestation the operator gives on the second\n * call is given with the numbers in front of them. Both routes run the SAME\n * resolution over the SAME inputs — `resolveAddresses` — so the summary they\n * were shown is the summary that acts.\n *\n * ## Finding people is a read, and it uses the same gate\n *\n * `email/list-rule-preview` answers \"who do these filters select\" without\n * writing anything, so a fixed list can be filled from a search rather than\n * from somebody typing addresses one at a time. It runs the addresses it finds\n * through `resolveAddresses` as well — a bulk path that reached the membership\n * without the suppression check and the attestation would be a way to enroll\n * exactly the people the single-address path refuses.\n *\n * ## The gate itself is `server-list-gate.ts`\n *\n * Who may change a list's membership, and what is true about each address, are\n * asked by these two routes and by the file importer (the list-member transfer\n * resource, `transfer/list-members.server.ts`). They live in a module of their\n * own so the importer shares this file's answer rather than importing this\n * file — which would be a cycle, and a cycle whose module-level constants\n * would evaluate in whichever order the loader happened to reach them.\n */\n\nimport { registerEmailDesignDraftWriter } from './server-email-drafts'\nimport { registerEmailSuppressionsApi } from './server-suppressions'\nimport { registerEmailListImportApi } from './server-list-import'\nimport {\n LIST_MEMBER_BATCH_MAX,\n readAddresses,\n resolveAddresses,\n resolveListContext,\n} from './server-list-gate'\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n dynamicListRuleIsEmpty,\n normalizeContactEmail,\n normalizeDynamicListRule,\n readMarketingBasis,\n type MarketingConsentRecord,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n collectDynamicListCandidates,\n consentGroupForSite,\n enrollListMember,\n firebaseAdmin,\n orgDataCollectionForHost,\n} from '@aglyn/tenant-data-admin'\nimport {\n CONTACT_LOOKUP_CHUNK,\n withContactConsentForRuleMembers,\n} from '@aglyn/tenant-data-admin/server/consent-by-address'\nimport { registerSubscriptionTopicIndex } from './server-topic-index'\n\n/** `source` stamped on every membership these routes write. */\nexport const CONSOLE_ADD_SOURCE = 'console:list-add'\n\n/*\n * Re-exported, not redefined. `@aglyn/plugins-email/server` has published\n * these names since the add path shipped, and the console panel and its specs\n * import them from there; moving the gate must not move the plugin's public\n * surface with it.\n */\nexport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type AddressResolution,\n type AddressVerdict,\n type ListContext,\n type ResolvedBatch,\n} from './server-list-gate'\n\n\n/**\n * `POST email/list-members-preview` — what adding these addresses would do.\n *\n * Reads only, and reached by an explicit act — typing an address or pasting a\n * column — never on mount. It exists because the answer needs three things\n * the browser cannot have: the org's contacts (rules put them behind org-wide\n * membership, which the acting console session may not hold) and both\n * suppression lists. Computing any of it client-side would be a second copy\n * of the rule, on the surface whose whole job is to tell the operator the\n * truth about what is about to happen.\n *\n * It takes no attestation and has nowhere to put one. The preview answers\n * what is TRUE about each person, which is the input to the operator's\n * decision rather than a function of it — a preview whose numbers moved as\n * the box was ticked would not be a count anybody could stand behind.\n */\nexport const emailListMembersPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const { verdicts, optedIn, needAttestation, refused } =\n await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n /*\n * Named fields rather than a spread of the resolution.\n *\n * The resolution carries the consent RECORDS it was computed from, for the\n * write path's use — raw stored basis, provenance, the account behind an\n * operator assertion. None of that is the browser's, and a spread would\n * put all of it on the wire the moment a field was added to the internal\n * shape. What the surface needs is the verdicts and the counts.\n */\n return res.status(200).json({\n listName: context.listName,\n verdicts,\n optedIn,\n needAttestation,\n refused,\n })\n } catch (error) {\n console.error('[email] list member preview failed', error)\n return res.status(500).json({ error: 'The addresses could not be checked.' })\n }\n}\n\n/** The most member rows one consent read may name — one table page and then some. */\nexport const LIST_MEMBER_CONSENT_MAX = 100\n\n/**\n * `POST email/list-member-consent` — the basis a send will act on, for the\n * rows of a list a RULE enrolled.\n *\n * Body: `{ hostId, listId, emails[] }`. Answers `{ consent: { [email]:\n * { basis, assertedBy, basisAtMs, otherGrant } } }` for each named address\n * that is a `via: 'rule'` member of the list with no basis on its own row;\n * every other address is left out, and the table keeps reading those rows\n * itself.\n *\n * A rule-enrolled row never holds a basis — matching a rule is not an opt-in\n * — so `performCampaignSend` reads those people's contacts instead, through\n * `withContactConsentForRuleMembers`. The column calls this so it shows the\n * same answer; the browser cannot make the read itself, because the rules\n * put the org's contacts behind org-wide membership and a `visibleTo` scope\n * that an address lookup cannot be combined with.\n *\n * The member rows are re-read here rather than taken from the request, so a\n * caller cannot get a contact's consent read for an address that is not a\n * rule member of this list. Reads only, behind the same gate as the preview.\n */\nexport const emailListMemberConsentHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const raw: unknown[] = Array.isArray(req.body?.emails) ? req.body.emails : []\n const emails = [\n ...new Set(\n raw\n .map((value) => normalizeContactEmail(value))\n .filter((value): value is string => Boolean(value)),\n ),\n ]\n if (emails.length > LIST_MEMBER_CONSENT_MAX) {\n return res.status(400).json({\n error: `At most ${LIST_MEMBER_CONSENT_MAX} addresses per request.`,\n })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n if (!emails.length) return res.status(200).json({ consent: {} })\n const group = await consentGroupForSite(context.hostId)\n const records = new Map<string, MarketingConsentRecord>()\n const ruleMembers = new Set<string>()\n const members = context.listRef.collection('members')\n for (let at = 0; at < emails.length; at += CONTACT_LOOKUP_CHUNK) {\n const chunk = emails.slice(at, at + CONTACT_LOOKUP_CHUNK)\n const snapshot = await members.where('email', 'in', chunk).get()\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email || doc.get('via') !== 'rule') continue\n ruleMembers.add(email)\n records.set(\n email,\n readMarketingBasis(doc.data() as Record<string, unknown>, group),\n )\n }\n }\n // Only the rows the overlay will fill are answered.\n const unrecorded = new Set(\n [...ruleMembers].filter(\n (email) => records.get(email)?.basis === 'unrecorded',\n ),\n )\n await withContactConsentForRuleMembers(\n orgDataCollectionForHost(context.hostId, 'contacts'),\n group,\n records,\n unrecorded,\n )\n const consent: Record<string, Record<string, unknown>> = {}\n for (const email of unrecorded) {\n const record = records.get(email)\n if (!record) continue\n // Named fields: the record also carries provenance that is not the\n // browser's (see the preview route above).\n consent[email] = {\n basis: record.basis,\n assertedBy: record.assertedBy,\n basisAtMs: record.basisAtMs,\n otherGrant: record.otherGrant,\n }\n }\n return res.status(200).json({ consent })\n } catch (error) {\n console.error('[email] list member consent read failed', error)\n return res.status(500).json({ error: 'Consent could not be read.' })\n }\n}\n\n/**\n * `POST email/list-members-add` — put these addresses on the list.\n *\n * Body: `{ hostId, listId, email | emails[], name?, attestConsent? }`.\n * `attestConsent` is the operator STATING that they have these people's\n * permission; it is not a way to name a basis, because the pass-through basis\n * is derived server-side from each person's own record.\n *\n * ## One attestation, for the count the operator was shown\n *\n * The batch carries a single assertion because it is a single act: an\n * operator pasting a column is making one claim about where that column came\n * from. What makes that safe is that the claim is applied per address by the\n * same function the preview ran, so it reaches only the addresses that\n * actually need it — an address with a stored opt-in keeps its own basis and\n * its own date, and an address nothing can enroll is refused with the\n * attestation on the table.\n *\n * ## Partial success is the honest answer\n *\n * A batch where one address is suppressed and forty are fine is not a failed\n * request. Every address comes back with what happened to it, so 200 here\n * means \"the request was processed\", never \"everybody was added\" — the caller\n * reads the per-address verdicts, which is why they are returned rather than\n * a count.\n */\nexport const emailListMembersAddHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n const attested = req.body?.attestConsent === true\n const name = String(req.body?.name ?? '').trim()\n\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const nowMs = Date.now()\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n\n const results = []\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: verdict.refusal,\n error: verdict.summary,\n })\n continue\n }\n /*\n * The ONE place a basis is decided, and the only place the attestation\n * is consulted.\n *\n * `resolveAddresses` has already answered the questions that are facts\n * about the person; this answers the question that is about the\n * operator, per address, from the consent record that resolution read.\n * `no-basis` — an address with nothing on record and nobody asserting\n * anything — is refused HERE and only here.\n */\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: decision.refusal,\n error: ASSIGNMENT_REFUSAL_MESSAGES[decision.refusal],\n })\n continue\n }\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(name && requested.emails.length === 1 ? { name } : {}),\n source: CONSOLE_ADD_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when a person stops matching, and a decision somebody\n // made by hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in'\n ? { grantEntries: resolution.grants.get(verdict.email) ?? {} }\n : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds\n * the row, so it is the backstop for every enrollment route; reaching\n * it here means the two records disagree, and the refusal wins.\n */\n const reason: AssignmentRefusal =\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address'\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n continue\n }\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: true,\n memberId: enrollment.memberId,\n created: enrollment.created,\n basis: decision.basis,\n })\n }\n\n return res.status(200).json({\n listName: context.listName,\n added: results.filter((result) => result.enrolled).length,\n results,\n })\n } catch (error) {\n console.error('[email] list member add failed', error)\n return res.status(500).json({ error: 'The addresses could not be added.' })\n }\n}\n\n/**\n * WHO A SET OF FILTERS FINDS, and what would happen if you added them.\n *\n * ## The gap it fills\n *\n * The filters behind a list could only ever be MATERIALIZED — the sweep wrote\n * the matching people straight into the membership. A merchant could not ask\n * \"who is this\" without committing to it, and a fixed list could not use the\n * filters at all: its only way to gain a member was somebody typing or pasting\n * an address. This answers the question without writing anything.\n *\n * ## It is the same consent gate, not a second one\n *\n * The addresses the scan finds are put through {@link resolveAddresses} — the\n * exact function `email/list-members-preview` uses — so a suppressed address\n * is reported as refused here, and somebody with no opt-in on record is\n * reported as needing an attestation here, before any of them is offered for\n * adding. That is deliberate and load-bearing: a bulk path that skipped the\n * check would be a way to fill a list with people the one-at-a-time path\n * refuses, which is the defect class this product already has a register entry\n * for. The ADD still goes through `email/list-members-add`, which re-runs\n * every check server-side, so this preview is an honest readout rather than a\n * permission.\n *\n * ## What it will not do\n *\n * It writes nothing, it enrolls nobody, and it compares no count against any\n * limit. The batch cap below bounds how many addresses it hands back for a\n * single add — the scan itself still reports how many people it MATCHED, so a\n * merchant is told the audience is larger than one batch rather than shown a\n * truncated number as if it were the whole.\n */\nexport const emailListRulePreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const rule = normalizeDynamicListRule(req.body?.rule)\n if (dynamicListRuleIsEmpty(rule)) {\n /*\n * Not an error, and not an empty result either. A rule with no source\n * matches nobody by construction, and reporting that as \"0 people\" is\n * indistinguishable from a rule that ran and found none — which is the\n * confusion the `empty` flag exists to prevent everywhere else.\n */\n return res.status(200).json({\n matched: 0,\n truncated: false,\n complete: true,\n empty: true,\n emails: [],\n verdicts: [],\n optedIn: 0,\n needAttestation: 0,\n refused: 0,\n })\n }\n const scan = await collectDynamicListCandidates({\n hostId: context.hostId,\n rule,\n })\n /*\n * The addresses, in the scan's own order, capped at what one add can\n * carry. `matched` is reported separately and is NOT this length: a\n * merchant looking at a 400-person audience must be told it is 400 even\n * when the button in front of them adds 100.\n */\n const emails = scan.candidates\n .map((candidate) => candidate.email)\n .filter(Boolean)\n const batch = emails.slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: batch,\n })\n return res.status(200).json({\n listName: context.listName,\n matched: emails.length,\n /*\n * Two different reasons a readout can be short of the truth, reported\n * apart: `truncated` is this batch being smaller than the match, and\n * `complete: false` is the SCAN having run out of budget, so `matched`\n * is itself a floor.\n */\n truncated: emails.length > batch.length,\n complete: scan.complete,\n empty: false,\n emails: batch,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list rule preview failed', error)\n return res\n .status(500)\n .json({ error: 'The audience could not be worked out.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`, so each is counted against the console\n * dispatcher's per-subject budget — the ceiling that bounds a surface which\n * puts a person into a marketing audience, and which scans a whole contact\n * silo to work out who that would be.\n *\n * The limiter these are counted by is the CONSOLE one,\n * `consoleApiRateLimitRefusal`. The visitor limiter is a different limiter on\n * a different dispatcher: `visitorWriteRateLimitRefusal` is installed only in\n * `apps/tenant`, and no request to these routes has ever passed through it.\n * The two share a counter and an exemption list and nothing else — the console\n * key is the authenticated subject, because on an authenticated surface a\n * per-(site, IP) bucket both isolates nothing the identity has not already\n * isolated and hands one operator a fresh budget per site.\n */\nexport function registerEmailConsoleApi(): void {\n // The email design writer another plugin reaches through the core's\n // resource-drafts seam (AGL-2912). Registered here alone: the AI jobs that\n // write through it run only on the console, inline and on the beat\n // (AGL-3026).\n registerEmailDesignDraftWriter()\n // The org's topic catalog, as the tenant surface publishes it: the console\n // runs sends under a stream too.\n registerSubscriptionTopicIndex(() => firebaseAdmin.app().firestore())\n registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler)\n registerPluginApiRoute(\n 'email/list-members-preview',\n emailListMembersPreviewHandler,\n )\n registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler)\n registerPluginApiRoute(\n 'email/list-member-consent',\n emailListMemberConsentHandler,\n )\n // The importer runs on the transfer framework (its resource registers from\n // the console declarations), and its permission step reads what the dry\n // run found through this one route — behind `resolveListContext`, the gate\n // the import's own writes reach through `resolveAddresses` too.\n registerEmailListImportApi()\n // Suppressions live in their own module: they are a per-SITE list gated on\n // the site role, where list membership is an ORG audience gated on org-wide\n // access, and one file holding both gates is one file for the wrong one to\n // be copied out of.\n registerEmailSuppressionsApi()\n}\n"],"names":["registerEmailDesignDraftWriter","registerEmailSuppressionsApi","registerEmailListImportApi","LIST_MEMBER_BATCH_MAX","readAddresses","resolveAddresses","resolveListContext","ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","dynamicListRuleIsEmpty","normalizeContactEmail","normalizeDynamicListRule","readMarketingBasis","registerPluginApiRoute","collectDynamicListCandidates","consentGroupForSite","enrollListMember","firebaseAdmin","orgDataCollectionForHost","CONTACT_LOOKUP_CHUNK","withContactConsentForRuleMembers","registerSubscriptionTopicIndex","CONSOLE_ADD_SOURCE","emailListMembersPreviewHandler","req","res","method","status","json","error","requested","context","ok","body","verdicts","optedIn","needAttestation","refused","hostId","inputs","emails","listName","console","LIST_MEMBER_CONSENT_MAX","emailListMemberConsentHandler","raw","Array","isArray","Set","map","value","filter","Boolean","length","consent","group","records","Map","ruleMembers","members","listRef","collection","at","chunk","slice","snapshot","where","get","doc","docs","email","add","set","data","unrecorded","basis","record","assertedBy","basisAtMs","otherGrant","emailListMembersAddHandler","attested","attestConsent","name","String","trim","nowMs","Date","now","resolution","results","verdict","refusal","push","input","enrolled","reason","summary","decision","stored","actingUid","uid","enrollment","source","via","grantEntries","grants","memberId","created","added","result","emailListRulePreviewHandler","rule","matched","truncated","complete","empty","scan","candidates","candidate","batch","registerEmailConsoleApi","app","firestore"],"mappings":"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"}