@aglyn/plugins-crm 1.0.0-beta.168 → 1.0.0-beta.170

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/crm/src/lib/components/leads-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n ConsolePluginPageProps,\n CrmLeadFields,\n CrmLeadStatus,\n} from '@aglyn/aglyn'\nimport {\n mdiAccountArrowRight,\n mdiAccountCancelOutline,\n mdiAccountConvertOutline,\n mdiAccountTieOutline,\n mdiMagnify,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n type OrgMemberOptions,\n useOrgMemberOptions,\n} from '../hooks/use-org-member-options'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { useCrmSavedView } from '../hooks/use-crm-saved-view'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport { customFieldColumns } from './contact-custom-columns'\nimport { useCrmViewGrid } from '../hooks/use-crm-view-grid'\nimport { CRM_LIST_SLOTS, CrmColumnOrderProvider } from './crm-column-menu'\nimport { useOrgLeads } from '../hooks/use-org-leads'\nimport CrmViewsControl from './crm-views-control'\nimport RowActionsMenu from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useHostCampaigns,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Box,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n FormControl,\n InputAdornment,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n limit,\n orderBy,\n query,\n serverTimestamp,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useId, useMemo, useState } from 'react'\nimport { downloadTextFile } from '../model/contacts-csv'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n LEAD_FILTER_LABELS,\n LEAD_EMAIL_FILTER_LABELS,\n LEAD_EMAIL_FILTERS,\n LEAD_FILTERS,\n type LeadEmailFilter,\n type LeadFilter,\n leadMatchesCampaignFilter,\n leadMatchesEmailFilter,\n leadMatchesFilter,\n leadMatchesSearch,\n} from '../model/lead-filters'\nimport { type LeadCsvOptions, leadsCsv } from '../model/leads-csv'\nimport { LeadConvertDialog } from './lead-convert-dialog'\nimport {\n leadSourceLabel,\n leadSources,\n leadTimeLabel,\n} from './lead-history-card'\nimport { LeadImportButton } from './lead-import-drawer'\nimport NewLeadDrawer, { type NewLeadValues } from './new-lead-drawer'\nimport { useCrmApi } from './use-crm-api'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { CONVERT_PENDING_ERASURE_REASON } from './lead-properties-card'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { LeadStatusChip } from './lead-status-chip'\nimport LeadSurfacesNote from './lead-surfaces-note'\nimport { LeadUnqualifyDialog } from './lead-unqualify-dialog'\nimport LeadsBulkBar from './leads-bulk-bar'\nimport OrgLeadSurfacesNote from './org-lead-surfaces-note'\n\n/**\n * How many leads the section reads: the newest by last seen, plus one probe\n * row so \"there are more\" is a fact rather than a guess at the boundary.\n *\n * A CEILING and a client-side filter rather than a paged status query, and\n * the reason is the lead documents themselves. Every lead the capture door\n * writes carries NO `status` — the field exists only once somebody in the\n * CRM has touched the lead — and Firestore cannot select documents by a\n * field's absence: `where('status','in',[…])`, `!=` and `not-in` all skip a\n * document without the field. A server-side \"open leads\" query would\n * therefore hide every lead nobody has worked yet, which is the entire\n * population the section exists to show on the day it ships. So the query\n * is the one order every lead can satisfy (`lastSeenAtMs`, stamped on every\n * capture), and the status filter narrows the loaded window — said out loud\n * beneath the table whenever the window is not the whole collection.\n *\n * The window is then PAGED in memory under the shared footer, the way the\n * workspace pickers page a slice of a window they cannot re-key: the rows\n * are already in the snapshot, so turning a page costs nothing, and the\n * footer's count is exact because it counts the filtered window rather than\n * a collection nobody has measured.\n */\nconst LEADS_WINDOW = 200\n\n/**\n * One row of the list. `$id` keys the grid and `leadId` names the document,\n * and they are the same value at both levels (AGL-3275).\n *\n * `$id` used to be `{hostId}/{leadId}` at the organization level, because a\n * lead's id is a person key and the same person met by two sites was two\n * documents carrying it — so the id alone could not key a list that spanned\n * sites. One org collection ends that, and the row no longer carries a site\n * at all: which sites hold this person is `capturedByHostIds`, a fact about\n * the person rather than part of their address.\n */\ntype LeadRow = Record<string, unknown> &\n CrmLeadFields & { $id: string; leadId: string }\n\n/**\n * `/crm/leads` — the people a site has met but not yet qualified (AGL-2608).\n *\n * A section of its own, the way Salesforce keeps Leads apart from Contacts:\n * a lead is a capture — a form, a booking, a sign-up — that somebody has\n * still to work, and it converts into a contact, a company and a deal when\n * it is real. Reads `orgs/{orgId}/leads` narrowed by `visibleTo` to the sites\n * this viewer may see (AGL-3275) — the same collection and the same clause at\n * both levels, which is what lets ONE listener serve a section that used to\n * open one per site.\n *\n * Under a site the clause names that site; at the ORGANIZATION level an\n * org-wide member reads without one, since the rules short-circuit on\n * `isOrgWideMember()` and a clause would only narrow what they may already\n * read. The per-site notes — which of a site's forms file a lead — belong to\n * a site's own hub and are not drawn here.\n */\nexport function CrmLeadsSection(props: ConsolePluginPageProps) {\n const { hostId, org, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { orgId, createHostId } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const roster = useOrgMemberOptions(orgId)\n // The org's lead fields, for the optional columns below (AGL-3272).\n const leadFields = useContactFieldDefinitions(orgId, 'lead')\n const routes = crmRoutes(basePath ?? '')\n\n /*\n * Under a site: the ORG collection, narrowed to what this site may see\n * (AGL-3275). It read `hosts/{hostId}/leads` until the silo moved, and\n * leaving it there would have shown a site its pre-migration rows and\n * nothing captured since — then nothing at all, once AGL-3276 emptied the\n * path it was reading.\n */\n const site = useFirestoreCollection<\n Record<string, unknown> & CrmLeadFields & { $id: string }\n >(\n () =>\n hostId && orgId\n ? query(\n collection(firestore, 'orgs', orgId, 'leads'),\n where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)),\n orderBy('lastSeenAtMs', 'desc'),\n limit(LEADS_WINDOW + 1),\n )\n : null,\n [firestore, hostId, orgId],\n { idField: '$id' },\n )\n // At the organization level: every site's window, merged.\n const orgHostIds = useMemo(\n () => (hostId ? [] : (mount?.hosts ?? []).map((host) => host.id)),\n [hostId, mount?.hosts],\n )\n /*\n * ONE LISTENER (AGL-3275), where this used to fan out across the org's\n * sites and merge. At the organization level an org-wide member reads with\n * no scope clause, which is what `visibleTo: null` asks for.\n */\n const orgLeads = useOrgLeads({\n orgId,\n visibleTo: null,\n windowSize: LEADS_WINDOW,\n })\n const leadDocs = useMemo<LeadRow[]>(\n () =>\n hostId\n ? site.data.map((row) => ({ ...row, leadId: row.$id }))\n : orgLeads.data,\n [hostId, site.data, orgLeads.data],\n )\n const status = hostId\n ? site.status\n : mount?.hostsReady && !orgHostIds.length\n ? 'success'\n : orgLeads.status\n const truncated = hostId ? leadDocs.length > LEADS_WINDOW : orgLeads.truncated\n const window = useMemo(() => leadDocs.slice(0, LEADS_WINDOW), [leadDocs])\n\n /*\n * The `Show` filter is the saved VIEW'S (AGL-2617): a saved view of leads\n * holds the status beside the columns and the sort, and the select below\n * writes into it. Unset reads as `open`, which is what the section opened\n * on before views existed and the one reading a query cannot express.\n */\n const views = useCrmSavedView({\n section: 'leads',\n hostId,\n org: props.org,\n basePath: basePath ?? '',\n })\n const filter: LeadFilter = useMemo(() => {\n const value = views.state.filters.find(\n (clause) => clause.field === 'status',\n )?.value\n return (LEAD_FILTERS as readonly string[]).includes(value ?? '')\n ? (value as LeadFilter)\n : 'open'\n }, [views.state.filters])\n /*\n * The `Email` filter (AGL-3245) is the view's too, as an `emailState`\n * clause beside the status one. Each setter keeps the other's clause:\n * narrowing to bounced leads does not reopen the unqualified ones.\n */\n const emailFilter: LeadEmailFilter = useMemo(() => {\n const value = views.state.filters.find(\n (clause) => clause.field === 'emailState',\n )?.value\n return (LEAD_EMAIL_FILTERS as readonly string[]).includes(value ?? '')\n ? (value as LeadEmailFilter)\n : 'any'\n }, [views.state.filters])\n const setFilter = useCallback(\n (next: LeadFilter) =>\n views.setFilters([\n ...views.state.filters.filter((clause) => clause.field !== 'status'),\n ...(next === 'open'\n ? []\n : [{ field: 'status', op: 'equals', value: next }]),\n ]),\n [views.setFilters, views.state.filters],\n )\n const setEmailFilter = useCallback(\n (next: LeadEmailFilter) =>\n views.setFilters([\n ...views.state.filters.filter(\n (clause) => clause.field !== 'emailState',\n ),\n ...(next === 'any'\n ? []\n : [{ field: 'emailState', op: 'equals', value: next }]),\n ]),\n [views.setFilters, views.state.filters],\n )\n /*\n * The `Campaign` filter (AGL-3254) is the view's too, as a `campaignIds`\n * clause: the id of one of the site's campaign containers, resolved to\n * its name from the containers themselves — ids only in storage, so a\n * renamed campaign keeps its leads. Under a site alone: a campaign\n * belongs to one site, and the organization-level list spans them all.\n */\n const campaignFilter = useMemo(\n () =>\n views.state.filters.find((clause) => clause.field === 'campaignIds')\n ?.value ?? '',\n [views.state.filters],\n )\n const setCampaignFilter = useCallback(\n (next: string) =>\n views.setFilters([\n ...views.state.filters.filter(\n (clause) => clause.field !== 'campaignIds',\n ),\n ...(next\n ? [{ field: 'campaignIds', op: 'contains', value: next }]\n : []),\n ]),\n [views.setFilters, views.state.filters],\n )\n const campaigns = useHostCampaigns(hostId, { enabled: Boolean(hostId) })\n const campaignName = useCallback(\n (id: string) =>\n campaigns.options.find((option) => option.value === id)?.label ?? id,\n [campaigns.options],\n )\n // The label's id, so the filter's combobox is named \"Show\" rather than\n // after the option it shows — see `LeadOwnerSelect`.\n const filterLabelId = useId()\n const emailFilterLabelId = useId()\n const campaignFilterLabelId = useId()\n /*\n * The search box is the SECTION'S, not the grid's (AGL-3246). The grid's\n * quick filter runs over the rows the grid holds, and the grid holds one\n * PAGE of the window — so a lead on page three answered \"no match\" while\n * the footer below went on counting the unfiltered window. The term\n * narrows the whole loaded window here, beside the status filter and\n * before the footer's count and the page slice, over the fields a person\n * types to find a lead: name, email, company, title and tags.\n */\n const [search, setSearch] = useState('')\n const rows = useMemo(\n () =>\n window.filter(\n (lead) =>\n leadMatchesFilter(lead, filter) &&\n leadMatchesEmailFilter(lead, emailFilter) &&\n leadMatchesCampaignFilter(lead, campaignFilter) &&\n leadMatchesSearch(lead, search),\n ),\n [window, filter, emailFilter, campaignFilter, search],\n )\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n // A new filter or search term starts on page one: page three of the open\n // leads is not a page of the unqualified ones, and an out-of-range page\n // renders empty.\n useEffect(() => {\n setPage(0)\n }, [filter, emailFilter, campaignFilter, search])\n const pageRows = useMemo(\n () => rows.slice(page * pageSize, (page + 1) * pageSize),\n [rows, page, pageSize],\n )\n\n /*\n * The ticked rows, for the bulk bar (AGL-2662). Cleared when the filter or\n * the search term changes: a selection made on the open leads is not a\n * selection of the unqualified ones, and the bar's count would be over\n * rows no longer listed.\n */\n const [selectedIds, setSelectedIds] = useState<string[]>([])\n useEffect(\n () => setSelectedIds([]),\n [filter, emailFilter, campaignFilter, search],\n )\n // How the file names the owner and, at the org level, the site.\n const csvOptions: LeadCsvOptions = useMemo(\n () => ({\n ownerEmail: roster.emailFor,\n ...(hostId ? {} : { siteName: (id: string) => mount?.siteName(id) }),\n }),\n [roster.emailFor, hostId, mount],\n )\n // The listed window — every row the filter and the search admit, not just\n // the page.\n const handleExport = useCallback(() => {\n downloadTextFile('leads.csv', 'text/csv', leadsCsv(rows, csvOptions))\n }, [rows, csvOptions])\n\n const [assigning, setAssigning] = useState<LeadRow | null>(null)\n const [unqualifying, setUnqualifying] = useState<LeadRow | null>(null)\n // The row whose conversion dialog is open (AGL-2641) — the same dialog\n // the lead's page opens, fed the row so the list is one click shorter.\n const [converting, setConverting] = useState<LeadRow | null>(null)\n\n /*==========================================\n * NEW LEAD (AGL-3231) — Salesforce's New Lead, in a drawer over the\n * list. The route files the lead through the one lead door under the\n * mounted site, or at the organization level under the site the drawer's\n * picker named; the drawer holds its submit until one is known. It makes\n * a lead and nothing else — the conversion is what makes the contact.\n *=========================================*/\n const crmApi = useCrmApi(createHostId)\n const [createOpen, setCreateOpen] = useState(false)\n const [createBusy, setCreateBusy] = useState(false)\n const [createError, setCreateError] = useState<string | null>(null)\n const handleCreate = useCallback(\n async (values: NewLeadValues) => {\n setCreateBusy(true)\n setCreateError(null)\n try {\n const { response, payload } = await crmApi('leads-create', {\n email: values.email,\n ...(values.name ? { name: values.name } : {}),\n ...(values.company ? { company: values.company } : {}),\n ...(values.jobTitle ? { jobTitle: values.jobTitle } : {}),\n ...(values.phone ? { phone: values.phone } : {}),\n ...(values.website ? { website: values.website } : {}),\n ...(values.leadSource ? { leadSource: values.leadSource } : {}),\n ...(values.address ? { address: values.address } : {}),\n ...(values.tags.length ? { tags: values.tags } : {}),\n ...(values.campaignIds.length\n ? { campaignIds: values.campaignIds }\n : {}),\n ...(values.ownerUid ? { ownerUid: values.ownerUid } : {}),\n ...(values.notes ? { notes: values.notes } : {}),\n // The org's own lead fields (AGL-3272), sent only when one was\n // filled — the route reads the definitions to judge the map, and\n // a body without it pays for no read.\n ...(values.custom ? { custom: values.custom } : {}),\n status: values.status,\n })\n if (!response.ok) {\n // The route's own sentence, shown above the form unchanged.\n setCreateError(\n String(payload['error'] ?? 'The lead could not be added.'),\n )\n return\n }\n // The activity entry is the route's: it verified the caller and\n // performed the write.\n enqueueSnackbar(\n payload['created']\n ? 'Lead added'\n : 'This site already held a lead for that address — it was updated',\n { variant: 'success', persist: false },\n )\n setCreateOpen(false)\n } catch (error) {\n console.error(error)\n setCreateError('The lead could not be added.')\n } finally {\n setCreateBusy(false)\n }\n },\n [crmApi, enqueueSnackbar],\n )\n\n const writeLead = useCallback(\n async (lead: LeadRow, fields: Record<string, unknown>, done: string) => {\n if (!orgId) {\n enqueueSnackbar('Still loading this workspace — try again in a moment.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n try {\n await updateDoc(\n doc(firestore, 'orgs', orgId, 'leads', lead.leadId),\n {\n ...fields,\n updatedAt: serverTimestamp(),\n },\n )\n enqueueSnackbar(done, { variant: 'success', persist: false })\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error\n ? error.message\n : 'The lead could not be updated.',\n { variant: 'error' },\n )\n }\n },\n [firestore, enqueueSnackbar],\n )\n\n const columns = useMemo<GridColDef[]>(\n () => [\n {\n field: 'name',\n headerName: 'Lead',\n flex: 1.4,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) =>\n String(row['name'] || row['email'] || ''),\n renderCell: ({ row }: { row: LeadRow }) => (\n <Stack\n spacing={0}\n sx={{ minWidth: 0, justifyContent: 'center', height: '100%' }}\n >\n <Typography variant=\"body2\" noWrap>\n {String(row['name'] || row['email'] || row.$id)}\n </Typography>\n {row['name'] ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {String(row['email'] ?? '')}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n // The lead's own profile (AGL-3231): the two facts a work queue is\n // scanned by, beside the person.\n {\n field: 'company',\n headerName: 'Company',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => String(row.company ?? ''),\n },\n {\n field: 'jobTitle',\n headerName: 'Title',\n flex: 0.9,\n minWidth: 130,\n valueGetter: (_value, row: LeadRow) => String(row.jobTitle ?? ''),\n },\n {\n field: 'status',\n headerName: 'Status',\n flex: 0.9,\n minWidth: 150,\n valueGetter: (_value, row: LeadRow) => Aglyn.crmLeadStatus(row),\n renderCell: ({ row }: { row: LeadRow }) => (\n <InlineStatus\n lead={row}\n onChange={(next) => {\n if (next === 'unqualified') {\n setUnqualifying(row)\n return\n }\n void writeLead(\n row,\n {\n status: next,\n ...(Aglyn.crmLeadStatus(row) === 'unqualified'\n ? { unqualifiedReason: deleteField() }\n : {}),\n },\n 'Status updated',\n )\n }}\n />\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the chip the lead's page\n * carries, so a bounced lead is told apart in the queue it is worked\n * from; its label for the sort and the export.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.9,\n minWidth: 150,\n valueGetter: (_value, row: LeadRow) => {\n const state = Aglyn.readEmailState(row)\n return state ? Aglyn.EMAIL_STATE_LABELS[state.status] : ''\n },\n renderCell: ({ row }: { row: LeadRow }) => {\n const state = Aglyn.readEmailState(row)\n return state ? (\n <CrmEmailStateChip state={state} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n )\n },\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n row.ownerUid ? roster.labelFor(row.ownerUid) : 'Unassigned',\n },\n // Only at the organization level, where a row can be any site's.\n ...(hostId\n ? []\n : [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 0.9,\n minWidth: 140,\n /*\n * KNOWN BY, not \"the site\" (AGL-3275). A lead is one org row\n * and several sites in a consent group can hold the same\n * person, so this names every site that captured them — the\n * answer the Contacts list has always given in its own column.\n */\n valueGetter: (_value: unknown, row: LeadRow) => {\n const held = Array.isArray(row['capturedByHostIds'])\n ? (row['capturedByHostIds'] as string[])\n : []\n return held.map((id) => mount?.siteName(id) ?? id).join(', ')\n },\n } satisfies GridColDef,\n ]),\n {\n field: 'sources',\n headerName: 'Source',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n leadSources(row).map(leadSourceLabel).join(', '),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 0.9,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => (row.tags ?? []).join(', '),\n },\n // The campaigns the lead is filed under (AGL-3254), by name — the\n // ids are the storage. Under a site only, like the filter.\n ...(hostId\n ? [\n {\n field: 'campaignIds',\n headerName: 'Campaign',\n flex: 1,\n minWidth: 150,\n valueGetter: (_value: unknown, row: LeadRow) =>\n Aglyn.readCampaignIds(row).map(campaignName).join(', '),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'lastSeenAtMs',\n headerName: 'Last seen',\n flex: 0.9,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) =>\n leadTimeLabel(row['lastSeenAtMs'] ?? row['createdAt']),\n },\n // The org's lead fields as optional columns (AGL-3272), read off the\n // row's own `custom` map the way the contacts list reads its own.\n // Ahead of the row menu, so the overflow stays at the right edge\n // however many fields the org has defined.\n ...customFieldColumns(leadFields.active),\n {\n field: 'actions',\n headerName: '',\n width: 56,\n sortable: false,\n filterable: false,\n disableColumnMenu: true,\n renderCell: ({ row }: { row: LeadRow }) => {\n /*\n * Why Convert is refused, in the order the lead's page refuses it:\n * a converted lead has its contact already, a closed one was\n * judged not real, and a person with an erasure pending must not\n * be captured again — a conversion is a capture (AGL-2623).\n */\n const converted = Boolean(row.convertedContactId)\n const erasurePending = Aglyn.readErasureRequestedAtMs(row) !== null\n const convertRefusal = converted\n ? 'This lead was converted'\n : !Aglyn.isCrmLeadOpen(row)\n ? 'This lead was unqualified'\n : erasurePending\n ? CONVERT_PENDING_ERASURE_REASON\n : null\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{ display: 'flex', alignItems: 'center', height: '100%' }}\n >\n <RowActionsMenu\n label={String(row['email'] ?? row.$id)}\n items={[\n {\n key: 'open',\n label: 'Open lead',\n icon: (\n <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />\n ),\n href: routes.lead(row.leadId),\n },\n {\n key: 'convert',\n label: 'Convert…',\n icon: (\n <MdiIcon\n path={mdiAccountConvertOutline.path}\n size={0.8}\n />\n ),\n onClick: () => setConverting(row),\n disabled: convertRefusal !== null,\n disabledReason: convertRefusal ?? undefined,\n },\n {\n key: 'assign',\n label: 'Assign owner',\n icon: (\n <MdiIcon path={mdiAccountTieOutline.path} size={0.8} />\n ),\n onClick: () => setAssigning(row),\n },\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: (\n <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />\n ),\n onClick: () => setUnqualifying(row),\n disabled:\n !Aglyn.isCrmLeadOpen(row) ||\n Boolean(row.convertedContactId),\n disabledReason: row.convertedContactId\n ? 'This lead was converted'\n : 'This lead is already closed',\n },\n ]}\n />\n </Box>\n )\n },\n },\n ],\n [roster, routes, writeLead, hostId, mount, campaignName, leadFields.active],\n )\n /* The column and sort models are the view's (AGL-2617). */\n const grid = useCrmViewGrid(views, columns)\n\n return (\n <>\n <CardDisplay\n header={'Leads'}\n help={Aglyn.pluginDocsHelp('contacts', {\n anchor: '#whats-in-the-crm-area',\n })}\n actions={\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n {/* The saved view this list is showing, beside the status it narrows to (AGL-2617). */}\n <CrmViewsControl controller={views} allLabel=\"All leads\" />\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <InputLabel id={filterLabelId}>{'Show'}</InputLabel>\n <Select\n labelId={filterLabelId}\n label=\"Show\"\n value={filter}\n onChange={(event) =>\n setFilter(event.target.value as LeadFilter)\n }\n >\n {LEAD_FILTERS.map((option) => (\n <MenuItem key={option} value={option}>\n {LEAD_FILTER_LABELS[option]}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n {/* The verdict on the address (AGL-3245): the bounced, the blocked, the ones who left. */}\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <InputLabel id={emailFilterLabelId}>{'Email'}</InputLabel>\n <Select\n labelId={emailFilterLabelId}\n label=\"Email\"\n value={emailFilter}\n onChange={(event) =>\n setEmailFilter(event.target.value as LeadEmailFilter)\n }\n >\n {LEAD_EMAIL_FILTERS.map((option) => (\n <MenuItem key={option} value={option}>\n {LEAD_EMAIL_FILTER_LABELS[option]}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n {/* The campaign the lead is filed under (AGL-3254): the site's containers, by name. */}\n {hostId ? (\n <FormControl size=\"small\" sx={{ minWidth: 180 }}>\n <InputLabel id={campaignFilterLabelId} shrink>\n {'Campaign'}\n </InputLabel>\n <Select\n labelId={campaignFilterLabelId}\n label=\"Campaign\"\n notched\n value={campaignFilter}\n onChange={(event) =>\n setCampaignFilter(String(event.target.value))\n }\n displayEmpty\n >\n <MenuItem value=\"\">{'Any campaign'}</MenuItem>\n {campaigns.options.map((option) => (\n <MenuItem key={option.value} value={option.value}>\n {option.label}\n </MenuItem>\n ))}\n {/* A stored filter naming a campaign the site no longer lists stays selectable, by id, so it can be cleared. */}\n {campaignFilter &&\n !campaigns.options.some(\n (option) => option.value === campaignFilter,\n ) ? (\n <MenuItem value={campaignFilter}>{campaignFilter}</MenuItem>\n ) : null}\n </Select>\n </FormControl>\n ) : null}\n <TextField\n size=\"small\"\n value={search}\n onChange={(event) => setSearch(event.target.value)}\n placeholder=\"Search leads\"\n slotProps={{\n input: {\n startAdornment: (\n <InputAdornment position=\"start\">\n <MdiIcon path={mdiMagnify.path} size={0.8} />\n </InputAdornment>\n ),\n },\n htmlInput: { 'aria-label': 'Search leads', type: 'search' },\n }}\n sx={{ minWidth: 200 }}\n />\n <LeadImportButton hostId={hostId} />\n <Button size=\"small\" onClick={handleExport} disabled={!rows.length}>\n {'Export CSV'}\n </Button>\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setCreateOpen(true)}\n >\n {'New lead'}\n </Button>\n </Stack>\n }\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/* Which surfaces file a lead, by name (AGL-2612) — under a site its own, at the org level every site's (AGL-2638). */}\n {hostId ? (\n <LeadSurfacesNote hostId={hostId} />\n ) : (\n <OrgLeadSurfacesNote />\n )}\n {status === 'success' && window.length === 0 ? (\n <EmptyStateComponent\n label={'No leads yet'}\n description={\n 'Bookings and lead-routed forms on your site become leads on their own — or add one with New lead, or bring a list in with Import CSV.'\n }\n />\n ) : status === 'success' && rows.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`No ${LEAD_FILTER_LABELS[filter].toLowerCase()} leads` +\n (search.trim() ? ` match “${search.trim()}”` : '') +\n ` among the ${window.length.toLocaleString()} most recently seen.`}\n </Typography>\n ) : (\n <>\n <LeadsBulkBar\n rows={rows}\n selected={selectedIds}\n onSelectedChange={setSelectedIds}\n roster={roster}\n csv={csvOptions}\n orgId={orgId}\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n />\n <CrmColumnOrderProvider value={grid.columnOrder}>\n <ListTable\n rows={pageRows}\n columns={grid.columns}\n slots={CRM_LIST_SLOTS}\n selectable={{\n selected: selectedIds,\n onChange: setSelectedIds,\n }}\n loading={status === 'loading'}\n onOpen={(_id, row: LeadRow) =>\n router.push(\n routes.lead(row.leadId),\n )\n }\n // Columns and sort are the view's, controlled (AGL-2617).\n columnVisibilityModel={grid.columnVisibilityModel}\n onColumnVisibilityModelChange={\n grid.onColumnVisibilityModelChange\n }\n sortModel={grid.sortModel}\n onSortModelChange={grid.onSortModelChange}\n // The search is the section's, above: the grid's own box\n // would search this page alone.\n quickFilter={false}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n </CrmColumnOrderProvider>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={pageRows.length}\n count={rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`Showing the ${LEADS_WINDOW.toLocaleString()} most recently seen ` +\n 'leads. The search box and the status filter narrow these ' +\n `${LEADS_WINDOW.toLocaleString()} only; older leads are still ` +\n 'listed in the Inbox and reached by campaign audiences.'}\n </Alert>\n ) : null}\n </Stack>\n </CardDisplay>\n <NewLeadDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n hostId={hostId ?? null}\n org={org as Record<string, unknown> | undefined}\n busy={createBusy}\n error={createError}\n roster={roster}\n orgId={orgId}\n onSubmit={(values) => void handleCreate(values)}\n />\n <AssignOwnerDialog\n lead={assigning}\n roster={roster}\n onClose={() => setAssigning(null)}\n onAssign={(uid) => {\n if (!assigning) return\n void writeLead(\n assigning,\n { ownerUid: uid || deleteField() },\n uid ? 'Owner assigned' : 'Owner cleared',\n )\n setAssigning(null)\n }}\n />\n <LeadUnqualifyDialog\n open={Boolean(unqualifying)}\n onClose={() => setUnqualifying(null)}\n hostId={hostId ?? ''}\n leadId={unqualifying?.leadId ?? ''}\n leadLabel={String(\n unqualifying?.['name'] || unqualifying?.['email'] || '',\n )}\n />\n {/* The site the conversion is filed as (AGL-2641), which since\n AGL-3275 is the first site that captured this person rather than\n \"the site the row lives under\" — a lead shared by a consent group\n lives under none of them in particular. Under a site the mounted\n one is used, and the two agree for every single-brand org. */}\n <LeadConvertDialog\n open={Boolean(converting)}\n onClose={() => setConverting(null)}\n hostId={\n hostId ??\n (Array.isArray(converting?.['capturedByHostIds'])\n ? String((converting['capturedByHostIds'] as string[])[0] ?? '')\n : '')\n }\n orgId={orgId}\n org={org as Record<string, unknown> | undefined}\n leadId={converting?.leadId ?? ''}\n lead={converting ?? {}}\n basePath={basePath ?? ''}\n roster={roster}\n />\n </>\n )\n}\nCrmLeadsSection.displayName = 'CrmLeadsSection'\n\n/**\n * The status, editable in place for a lead that is still open.\n *\n * A converted lead shows the chip alone: its status IS the conversion, and\n * the route stamped it. The select stops its click from reaching the row, or\n * every status change would also open the record.\n */\nfunction InlineStatus(props: {\n lead: LeadRow\n onChange: (next: CrmLeadStatus) => void\n}) {\n const { lead, onChange } = props\n if (lead.convertedContactId) {\n return (\n <Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>\n <LeadStatusChip lead={lead} />\n </Box>\n )\n }\n const status = Aglyn.crmLeadStatus(lead)\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{\n display: 'flex',\n alignItems: 'center',\n height: '100%',\n width: '100%',\n }}\n >\n <Select\n size=\"small\"\n variant=\"standard\"\n disableUnderline\n value={status}\n onChange={(event) => onChange(event.target.value as CrmLeadStatus)}\n renderValue={() => <LeadStatusChip lead={lead} />}\n sx={{ width: '100%' }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">\n {Aglyn.CRM_LEAD_STATUS_LABELS.working}\n </MenuItem>\n <MenuItem value=\"unqualified\">{`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}</MenuItem>\n </Select>\n </Box>\n )\n}\nInlineStatus.displayName = 'InlineStatus'\n\n/** Hand a lead to a team member. */\nfunction AssignOwnerDialog(props: {\n lead: LeadRow | null\n roster: OrgMemberOptions\n onClose: () => void\n onAssign: (uid: string) => void\n}) {\n const { lead, roster, onClose, onAssign } = props\n const [uid, setUid] = useState<string | null>(null)\n const current = uid ?? String(lead?.ownerUid ?? '')\n return (\n <Dialog\n open={Boolean(lead)}\n onClose={onClose}\n maxWidth=\"xs\"\n fullWidth\n slotProps={{ transition: { onExited: () => setUid(null) } }}\n >\n <DialogTitle>{`Assign ${String(lead?.['name'] || lead?.['email'] || 'lead')}`}</DialogTitle>\n <DialogContent>\n <Box sx={{ pt: 1 }}>\n <LeadOwnerSelect\n value={current}\n onChange={setUid}\n roster={roster}\n size=\"medium\"\n />\n </Box>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose}>{'Cancel'}</Button>\n <Button variant=\"contained\" onClick={() => onAssign(current)}>\n {'Assign'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nAssignOwnerDialog.displayName = 'AssignOwnerDialog'\n\nexport default CrmLeadsSection\n"],"names":["Aglyn","mdiAccountArrowRight","mdiAccountCancelOutline","mdiAccountConvertOutline","mdiAccountTieOutline","mdiMagnify","CardDisplay","MdiIcon","ListPagination","ListTable","useOrgMemberOptions","useCrmOrgMount","useCrmSavedView","useCrmScope","scopeTokensForHost","useContactFieldDefinitions","customFieldColumns","useCrmViewGrid","CRM_LIST_SLOTS","CrmColumnOrderProvider","useOrgLeads","CrmViewsControl","RowActionsMenu","TABLE_PAGE_SIZE_DEFAULT","EmptyStateComponent","useSnackbar","useFirestore","useFirestoreCollection","useHostCampaigns","Alert","Box","Button","Dialog","DialogActions","DialogContent","DialogTitle","FormControl","InputAdornment","InputLabel","MenuItem","Select","Stack","TextField","Typography","collection","deleteField","doc","limit","orderBy","query","serverTimestamp","updateDoc","where","useRouter","useCallback","useEffect","useId","useMemo","useState","downloadTextFile","crmRoutes","LEAD_FILTER_LABELS","LEAD_EMAIL_FILTER_LABELS","LEAD_EMAIL_FILTERS","LEAD_FILTERS","leadMatchesCampaignFilter","leadMatchesEmailFilter","leadMatchesFilter","leadMatchesSearch","leadsCsv","LeadConvertDialog","leadSourceLabel","leadSources","leadTimeLabel","LeadImportButton","NewLeadDrawer","useCrmApi","LeadOwnerSelect","CONVERT_PENDING_ERASURE_REASON","CrmEmailStateChip","LeadStatusChip","LeadSurfacesNote","LeadUnqualifyDialog","LeadsBulkBar","OrgLeadSurfacesNote","LEADS_WINDOW","CrmLeadsSection","props","hostId","org","basePath","firestore","router","enqueueSnackbar","orgId","createHostId","mount","roster","leadFields","routes","site","idField","orgHostIds","hosts","map","host","id","orgLeads","visibleTo","windowSize","leadDocs","data","row","leadId","$id","status","hostsReady","length","truncated","window","slice","views","section","filter","value","state","filters","find","clause","field","includes","emailFilter","setFilter","next","setFilters","op","setEmailFilter","campaignFilter","setCampaignFilter","campaigns","enabled","Boolean","campaignName","options","option","label","filterLabelId","emailFilterLabelId","campaignFilterLabelId","search","setSearch","rows","lead","page","setPage","pageSize","setPageSize","pageRows","selectedIds","setSelectedIds","csvOptions","ownerEmail","emailFor","siteName","handleExport","assigning","setAssigning","unqualifying","setUnqualifying","converting","setConverting","crmApi","createOpen","setCreateOpen","createBusy","setCreateBusy","createError","setCreateError","handleCreate","values","response","payload","email","name","company","jobTitle","phone","website","leadSource","address","tags","campaignIds","ownerUid","notes","custom","ok","String","variant","persist","error","console","writeLead","fields","done","updatedAt","Error","message","columns","headerName","flex","minWidth","valueGetter","_value","renderCell","spacing","sx","justifyContent","height","noWrap","color","crmLeadStatus","InlineStatus","onChange","unqualifiedReason","readEmailState","EMAIL_STATE_LABELS","labelFor","held","Array","isArray","join","readCampaignIds","active","width","sortable","filterable","disableColumnMenu","converted","convertedContactId","erasurePending","readErasureRequestedAtMs","convertRefusal","isCrmLeadOpen","onClick","event","stopPropagation","display","alignItems","items","key","icon","path","size","href","disabled","disabledReason","undefined","grid","header","help","pluginDocsHelp","anchor","actions","direction","controller","allLabel","labelId","target","shrink","notched","displayEmpty","some","placeholder","slotProps","input","startAdornment","position","htmlInput","type","contentGutterX","contentGutterY","description","toLowerCase","trim","toLocaleString","selected","onSelectedChange","csv","columnOrder","slots","selectable","loading","onOpen","_id","push","columnVisibilityModel","onColumnVisibilityModelChange","sortModel","onSortModelChange","quickFilter","hideFooter","rowCount","count","onPageChange","onPageSizeChange","severity","open","onClose","busy","onSubmit","AssignOwnerDialog","onAssign","uid","leadLabel","displayName","disableUnderline","renderValue","CRM_LEAD_STATUS_LABELS","new","working","unqualified","setUid","current","maxWidth","fullWidth","transition","onExited","pt"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/leads-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n ConsolePluginPageProps,\n CrmLeadFields,\n CrmLeadStatus,\n} from '@aglyn/aglyn'\nimport {\n mdiAccountArrowRight,\n mdiAccountCancelOutline,\n mdiAccountConvertOutline,\n mdiAccountTieOutline,\n mdiMagnify,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n type OrgMemberOptions,\n useOrgMemberOptions,\n} from '../hooks/use-org-member-options'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { useCrmSavedView } from '../hooks/use-crm-saved-view'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport { customFieldColumns } from './contact-custom-columns'\nimport { useCrmViewGrid } from '../hooks/use-crm-view-grid'\nimport { CRM_LIST_SLOTS, CrmColumnOrderProvider } from './crm-column-menu'\nimport { useOrgLeads } from '../hooks/use-org-leads'\nimport CrmViewsControl from './crm-views-control'\nimport RowActionsMenu from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useHostCampaigns,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Box,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n FormControl,\n InputAdornment,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n limit,\n orderBy,\n query,\n serverTimestamp,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useId, useMemo, useState } from 'react'\nimport { downloadTextFile } from '../model/contacts-csv'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n LEAD_FILTER_LABELS,\n LEAD_EMAIL_FILTER_LABELS,\n LEAD_EMAIL_FILTERS,\n LEAD_FILTERS,\n type LeadEmailFilter,\n type LeadFilter,\n leadMatchesCampaignFilter,\n leadMatchesEmailFilter,\n leadMatchesFilter,\n leadMatchesSearch,\n} from '../model/lead-filters'\nimport { type LeadCsvOptions, leadsCsv } from '../model/leads-csv'\nimport { LeadConvertDialog } from './lead-convert-dialog'\nimport {\n leadSourceLabel,\n leadSources,\n leadTimeLabel,\n} from './lead-history-card'\nimport { LeadImportButton } from './lead-import-drawer'\nimport NewLeadDrawer, { type NewLeadValues } from './new-lead-drawer'\nimport { useCrmApi } from './use-crm-api'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { CONVERT_PENDING_ERASURE_REASON } from './lead-properties-card'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { LeadStatusChip } from './lead-status-chip'\nimport LeadSurfacesNote from './lead-surfaces-note'\nimport { LeadUnqualifyDialog } from './lead-unqualify-dialog'\nimport LeadsBulkBar from './leads-bulk-bar'\nimport OrgLeadSurfacesNote from './org-lead-surfaces-note'\n\n/**\n * How many leads the section reads: the newest by last seen, plus one probe\n * row so \"there are more\" is a fact rather than a guess at the boundary.\n *\n * A CEILING and a client-side filter rather than a paged status query, and\n * the reason is the lead documents themselves. Every lead the capture door\n * writes carries NO `status` — the field exists only once somebody in the\n * CRM has touched the lead — and Firestore cannot select documents by a\n * field's absence: `where('status','in',[…])`, `!=` and `not-in` all skip a\n * document without the field. A server-side \"open leads\" query would\n * therefore hide every lead nobody has worked yet, which is the entire\n * population the section exists to show on the day it ships. So the query\n * is the one order every lead can satisfy (`lastSeenAtMs`, stamped on every\n * capture), and the status filter narrows the loaded window — said out loud\n * beneath the table whenever the window is not the whole collection.\n *\n * The window is then PAGED in memory under the shared footer, the way the\n * workspace pickers page a slice of a window they cannot re-key: the rows\n * are already in the snapshot, so turning a page costs nothing, and the\n * footer's count is exact because it counts the filtered window rather than\n * a collection nobody has measured.\n */\nconst LEADS_WINDOW = 200\n\n/**\n * One row of the list. `$id` keys the grid and `leadId` names the document,\n * and they are the same value at both levels (AGL-3275).\n *\n * `$id` used to be `{hostId}/{leadId}` at the organization level, because a\n * lead's id is a person key and the same person met by two sites was two\n * documents carrying it — so the id alone could not key a list that spanned\n * sites. One org collection ends that, and the row no longer carries a site\n * at all: which sites hold this person is `capturedByHostIds`, a fact about\n * the person rather than part of their address.\n */\ntype LeadRow = Record<string, unknown> &\n CrmLeadFields & { $id: string; leadId: string }\n\n/**\n * `/crm/leads` — the people a site has met but not yet qualified (AGL-2608).\n *\n * A section of its own, the way Salesforce keeps Leads apart from Contacts:\n * a lead is a capture — a form, a booking, a sign-up — that somebody has\n * still to work, and it converts into a contact, a company and a deal when\n * it is real. Reads `orgs/{orgId}/leads` narrowed by `visibleTo` to the sites\n * this viewer may see (AGL-3275) — the same collection and the same clause at\n * both levels, which is what lets ONE listener serve a section that used to\n * open one per site.\n *\n * Under a site the clause names that site; at the ORGANIZATION level an\n * org-wide member reads without one, since the rules short-circuit on\n * `isOrgWideMember()` and a clause would only narrow what they may already\n * read. The per-site notes — which of a site's forms file a lead — belong to\n * a site's own hub and are not drawn here.\n */\nexport function CrmLeadsSection(props: ConsolePluginPageProps) {\n const { hostId, org, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { orgId, createHostId } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const roster = useOrgMemberOptions(orgId)\n // The org's lead fields, for the optional columns below (AGL-3272).\n const leadFields = useContactFieldDefinitions(orgId, 'lead')\n const routes = crmRoutes(basePath ?? '')\n\n /*\n * Under a site: the ORG collection, narrowed to what this site may see\n * (AGL-3275). It read `hosts/{hostId}/leads` until the silo moved, and\n * leaving it there would have shown a site its pre-migration rows and\n * nothing captured since — then nothing at all, once AGL-3276 emptied the\n * path it was reading.\n */\n const site = useFirestoreCollection<\n Record<string, unknown> & CrmLeadFields & { $id: string }\n >(\n () =>\n hostId && orgId\n ? query(\n collection(firestore, 'orgs', orgId, 'leads'),\n where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)),\n orderBy('lastSeenAtMs', 'desc'),\n limit(LEADS_WINDOW + 1),\n )\n : null,\n [firestore, hostId, orgId],\n { idField: '$id' },\n )\n // At the organization level: every site's window, merged.\n const orgHostIds = useMemo(\n () => (hostId ? [] : (mount?.hosts ?? []).map((host) => host.id)),\n [hostId, mount?.hosts],\n )\n /*\n * ONE LISTENER (AGL-3275), where this used to fan out across the org's\n * sites and merge. At the organization level an org-wide member reads with\n * no scope clause, which is what `visibleTo: null` asks for.\n */\n const orgLeads = useOrgLeads({\n orgId,\n visibleTo: null,\n windowSize: LEADS_WINDOW,\n })\n const leadDocs = useMemo<LeadRow[]>(\n () =>\n hostId\n ? site.data.map((row) => ({ ...row, leadId: row.$id }))\n : orgLeads.data,\n [hostId, site.data, orgLeads.data],\n )\n const status = hostId\n ? site.status\n : mount?.hostsReady && !orgHostIds.length\n ? 'success'\n : orgLeads.status\n const truncated = hostId ? leadDocs.length > LEADS_WINDOW : orgLeads.truncated\n const window = useMemo(() => leadDocs.slice(0, LEADS_WINDOW), [leadDocs])\n\n /*\n * The `Show` filter is the saved VIEW'S (AGL-2617): a saved view of leads\n * holds the status beside the columns and the sort, and the select below\n * writes into it. Unset reads as `open`, which is what the section opened\n * on before views existed and the one reading a query cannot express.\n */\n const views = useCrmSavedView({\n section: 'leads',\n hostId,\n org: props.org,\n basePath: basePath ?? '',\n })\n const filter: LeadFilter = useMemo(() => {\n const value = views.state.filters.find(\n (clause) => clause.field === 'status',\n )?.value\n return (LEAD_FILTERS as readonly string[]).includes(value ?? '')\n ? (value as LeadFilter)\n : 'open'\n }, [views.state.filters])\n /*\n * The `Email` filter (AGL-3245) is the view's too, as an `emailState`\n * clause beside the status one. Each setter keeps the other's clause:\n * narrowing to bounced leads does not reopen the unqualified ones.\n */\n const emailFilter: LeadEmailFilter = useMemo(() => {\n const value = views.state.filters.find(\n (clause) => clause.field === 'emailState',\n )?.value\n return (LEAD_EMAIL_FILTERS as readonly string[]).includes(value ?? '')\n ? (value as LeadEmailFilter)\n : 'any'\n }, [views.state.filters])\n const setFilter = useCallback(\n (next: LeadFilter) =>\n views.setFilters([\n ...views.state.filters.filter((clause) => clause.field !== 'status'),\n ...(next === 'open'\n ? []\n : [{ field: 'status', op: 'equals', value: next }]),\n ]),\n [views.setFilters, views.state.filters],\n )\n const setEmailFilter = useCallback(\n (next: LeadEmailFilter) =>\n views.setFilters([\n ...views.state.filters.filter(\n (clause) => clause.field !== 'emailState',\n ),\n ...(next === 'any'\n ? []\n : [{ field: 'emailState', op: 'equals', value: next }]),\n ]),\n [views.setFilters, views.state.filters],\n )\n /*\n * The `Campaign` filter (AGL-3254) is the view's too, as a `campaignIds`\n * clause: the id of one of the site's campaign containers, resolved to\n * its name from the containers themselves — ids only in storage, so a\n * renamed campaign keeps its leads. Under a site alone: a campaign\n * belongs to one site, and the organization-level list spans them all.\n */\n const campaignFilter = useMemo(\n () =>\n views.state.filters.find((clause) => clause.field === 'campaignIds')\n ?.value ?? '',\n [views.state.filters],\n )\n const setCampaignFilter = useCallback(\n (next: string) =>\n views.setFilters([\n ...views.state.filters.filter(\n (clause) => clause.field !== 'campaignIds',\n ),\n ...(next\n ? [{ field: 'campaignIds', op: 'contains', value: next }]\n : []),\n ]),\n [views.setFilters, views.state.filters],\n )\n const campaigns = useHostCampaigns(hostId, { enabled: Boolean(hostId) })\n const campaignName = useCallback(\n (id: string) =>\n campaigns.options.find((option) => option.value === id)?.label ?? id,\n [campaigns.options],\n )\n // The label's id, so the filter's combobox is named \"Show\" rather than\n // after the option it shows — see `LeadOwnerSelect`.\n const filterLabelId = useId()\n const emailFilterLabelId = useId()\n const campaignFilterLabelId = useId()\n /*\n * The search box is the SECTION'S, not the grid's (AGL-3246). The grid's\n * quick filter runs over the rows the grid holds, and the grid holds one\n * PAGE of the window — so a lead on page three answered \"no match\" while\n * the footer below went on counting the unfiltered window. The term\n * narrows the whole loaded window here, beside the status filter and\n * before the footer's count and the page slice, over the fields a person\n * types to find a lead: name, email, company, title and tags.\n */\n const [search, setSearch] = useState('')\n const rows = useMemo(\n () =>\n window.filter(\n (lead) =>\n leadMatchesFilter(lead, filter) &&\n leadMatchesEmailFilter(lead, emailFilter) &&\n leadMatchesCampaignFilter(lead, campaignFilter) &&\n leadMatchesSearch(lead, search),\n ),\n [window, filter, emailFilter, campaignFilter, search],\n )\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n // A new filter or search term starts on page one: page three of the open\n // leads is not a page of the unqualified ones, and an out-of-range page\n // renders empty.\n useEffect(() => {\n setPage(0)\n }, [filter, emailFilter, campaignFilter, search])\n const pageRows = useMemo(\n () => rows.slice(page * pageSize, (page + 1) * pageSize),\n [rows, page, pageSize],\n )\n\n /*\n * The ticked rows, for the bulk bar (AGL-2662). Cleared when the filter or\n * the search term changes: a selection made on the open leads is not a\n * selection of the unqualified ones, and the bar's count would be over\n * rows no longer listed.\n */\n const [selectedIds, setSelectedIds] = useState<string[]>([])\n useEffect(\n () => setSelectedIds([]),\n [filter, emailFilter, campaignFilter, search],\n )\n // How the file names the owner and, at the org level, the site.\n const csvOptions: LeadCsvOptions = useMemo(\n () => ({\n ownerEmail: roster.emailFor,\n ...(hostId ? {} : { siteName: (id: string) => mount?.siteName(id) }),\n }),\n [roster.emailFor, hostId, mount],\n )\n // The listed window — every row the filter and the search admit, not just\n // the page.\n const handleExport = useCallback(() => {\n downloadTextFile('leads.csv', 'text/csv', leadsCsv(rows, csvOptions))\n }, [rows, csvOptions])\n\n const [assigning, setAssigning] = useState<LeadRow | null>(null)\n const [unqualifying, setUnqualifying] = useState<LeadRow | null>(null)\n // The row whose conversion dialog is open (AGL-2641) — the same dialog\n // the lead's page opens, fed the row so the list is one click shorter.\n const [converting, setConverting] = useState<LeadRow | null>(null)\n\n /*==========================================\n * NEW LEAD (AGL-3231) — Salesforce's New Lead, in a drawer over the\n * list. The route files the lead through the one lead door under the\n * mounted site, or at the organization level under the site the drawer's\n * picker named; the drawer holds its submit until one is known. It makes\n * a lead and nothing else — the conversion is what makes the contact.\n *=========================================*/\n const crmApi = useCrmApi(createHostId)\n const [createOpen, setCreateOpen] = useState(false)\n const [createBusy, setCreateBusy] = useState(false)\n const [createError, setCreateError] = useState<string | null>(null)\n const handleCreate = useCallback(\n async (values: NewLeadValues) => {\n setCreateBusy(true)\n setCreateError(null)\n try {\n const { response, payload } = await crmApi('leads-create', {\n email: values.email,\n ...(values.name ? { name: values.name } : {}),\n ...(values.company ? { company: values.company } : {}),\n ...(values.jobTitle ? { jobTitle: values.jobTitle } : {}),\n ...(values.phone ? { phone: values.phone } : {}),\n ...(values.website ? { website: values.website } : {}),\n ...(values.leadSource ? { leadSource: values.leadSource } : {}),\n ...(values.address ? { address: values.address } : {}),\n ...(values.tags.length ? { tags: values.tags } : {}),\n ...(values.campaignIds.length\n ? { campaignIds: values.campaignIds }\n : {}),\n ...(values.ownerUid ? { ownerUid: values.ownerUid } : {}),\n ...(values.notes ? { notes: values.notes } : {}),\n // The org's own lead fields (AGL-3272), sent only when one was\n // filled — the route reads the definitions to judge the map, and\n // a body without it pays for no read.\n ...(values.custom ? { custom: values.custom } : {}),\n status: values.status,\n })\n if (!response.ok) {\n // The route's own sentence, shown above the form unchanged.\n setCreateError(\n String(payload['error'] ?? 'The lead could not be added.'),\n )\n return\n }\n // The activity entry is the route's: it verified the caller and\n // performed the write.\n enqueueSnackbar(\n payload['created']\n ? 'Lead added'\n : 'This site already held a lead for that address — it was updated',\n { variant: 'success', persist: false },\n )\n setCreateOpen(false)\n } catch (error) {\n console.error(error)\n setCreateError('The lead could not be added.')\n } finally {\n setCreateBusy(false)\n }\n },\n [crmApi, enqueueSnackbar],\n )\n\n const writeLead = useCallback(\n async (lead: LeadRow, fields: Record<string, unknown>, done: string) => {\n if (!orgId) {\n enqueueSnackbar('Still loading this workspace — try again in a moment.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n try {\n await updateDoc(\n doc(firestore, 'orgs', orgId, 'leads', lead.leadId),\n {\n ...fields,\n updatedAt: serverTimestamp(),\n },\n )\n enqueueSnackbar(done, { variant: 'success', persist: false })\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error\n ? error.message\n : 'The lead could not be updated.',\n { variant: 'error' },\n )\n }\n },\n [firestore, enqueueSnackbar],\n )\n\n const columns = useMemo<GridColDef[]>(\n () => [\n {\n field: 'name',\n headerName: 'Lead',\n flex: 1.4,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) =>\n String(row['name'] || row['email'] || ''),\n renderCell: ({ row }: { row: LeadRow }) => (\n <Stack\n spacing={0}\n sx={{ minWidth: 0, justifyContent: 'center', height: '100%' }}\n >\n <Typography variant=\"body2\" noWrap>\n {String(row['name'] || row['email'] || row.$id)}\n </Typography>\n {row['name'] ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {String(row['email'] ?? '')}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n // The lead's own profile (AGL-3231): the two facts a work queue is\n // scanned by, beside the person.\n {\n field: 'company',\n headerName: 'Company',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => String(row.company ?? ''),\n },\n {\n field: 'jobTitle',\n headerName: 'Title',\n flex: 0.9,\n minWidth: 130,\n valueGetter: (_value, row: LeadRow) => String(row.jobTitle ?? ''),\n },\n {\n field: 'status',\n headerName: 'Status',\n flex: 0.9,\n minWidth: 150,\n valueGetter: (_value, row: LeadRow) => Aglyn.crmLeadStatus(row),\n renderCell: ({ row }: { row: LeadRow }) => (\n <InlineStatus\n lead={row}\n onChange={(next) => {\n if (next === 'unqualified') {\n setUnqualifying(row)\n return\n }\n void writeLead(\n row,\n {\n status: next,\n ...(Aglyn.crmLeadStatus(row) === 'unqualified'\n ? { unqualifiedReason: deleteField() }\n : {}),\n },\n 'Status updated',\n )\n }}\n />\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the chip the lead's page\n * carries, so a bounced lead is told apart in the queue it is worked\n * from; its label for the sort and the export.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.9,\n minWidth: 150,\n valueGetter: (_value, row: LeadRow) => {\n const state = Aglyn.readEmailState(row)\n return state ? Aglyn.EMAIL_STATE_LABELS[state.status] : ''\n },\n renderCell: ({ row }: { row: LeadRow }) => {\n const state = Aglyn.readEmailState(row)\n return state ? (\n <CrmEmailStateChip state={state} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n )\n },\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n row.ownerUid ? roster.labelFor(row.ownerUid) : 'Unassigned',\n },\n // Only at the organization level, where a row can be any site's.\n ...(hostId\n ? []\n : [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 0.9,\n minWidth: 140,\n /*\n * KNOWN BY, not \"the site\" (AGL-3275). A lead is one org row\n * and several sites in a consent group can hold the same\n * person, so this names every site that captured them — the\n * answer the Contacts list has always given in its own column.\n */\n valueGetter: (_value: unknown, row: LeadRow) => {\n const held = Array.isArray(row['capturedByHostIds'])\n ? (row['capturedByHostIds'] as string[])\n : []\n return held.map((id) => mount?.siteName(id) ?? id).join(', ')\n },\n } satisfies GridColDef,\n ]),\n {\n field: 'sources',\n headerName: 'Source',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n leadSources(row).map(leadSourceLabel).join(', '),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 0.9,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => (row.tags ?? []).join(', '),\n },\n // The campaigns the lead is filed under (AGL-3254), by name — the\n // ids are the storage. Under a site only, like the filter.\n ...(hostId\n ? [\n {\n field: 'campaignIds',\n headerName: 'Campaign',\n flex: 1,\n minWidth: 150,\n valueGetter: (_value: unknown, row: LeadRow) =>\n Aglyn.readCampaignIds(row).map(campaignName).join(', '),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'lastSeenAtMs',\n headerName: 'Last seen',\n flex: 0.9,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) =>\n leadTimeLabel(row['lastSeenAtMs'] ?? row['createdAt']),\n },\n // The org's lead fields as optional columns (AGL-3272), read off the\n // row's own `custom` map the way the contacts list reads its own.\n // Ahead of the row menu, so the overflow stays at the right edge\n // however many fields the org has defined.\n ...customFieldColumns(leadFields.active),\n {\n field: 'actions',\n headerName: '',\n width: 56,\n sortable: false,\n filterable: false,\n disableColumnMenu: true,\n renderCell: ({ row }: { row: LeadRow }) => {\n /*\n * Why Convert is refused, in the order the lead's page refuses it:\n * a converted lead has its contact already, a closed one was\n * judged not real, and a person with an erasure pending must not\n * be captured again — a conversion is a capture (AGL-2623).\n */\n const converted = Boolean(row.convertedContactId)\n const erasurePending = Aglyn.readErasureRequestedAtMs(row) !== null\n const convertRefusal = converted\n ? 'This lead was converted'\n : !Aglyn.isCrmLeadOpen(row)\n ? 'This lead was unqualified'\n : erasurePending\n ? CONVERT_PENDING_ERASURE_REASON\n : null\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{ display: 'flex', alignItems: 'center', height: '100%' }}\n >\n <RowActionsMenu\n label={String(row['email'] ?? row.$id)}\n items={[\n {\n key: 'open',\n label: 'Open lead',\n icon: (\n <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />\n ),\n href: routes.lead(row.leadId),\n },\n {\n key: 'convert',\n label: 'Convert…',\n icon: (\n <MdiIcon\n path={mdiAccountConvertOutline.path}\n size={0.8}\n />\n ),\n onClick: () => setConverting(row),\n disabled: convertRefusal !== null,\n disabledReason: convertRefusal ?? undefined,\n },\n {\n key: 'assign',\n label: 'Assign owner',\n icon: (\n <MdiIcon path={mdiAccountTieOutline.path} size={0.8} />\n ),\n onClick: () => setAssigning(row),\n },\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: (\n <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />\n ),\n onClick: () => setUnqualifying(row),\n disabled:\n !Aglyn.isCrmLeadOpen(row) ||\n Boolean(row.convertedContactId),\n disabledReason: row.convertedContactId\n ? 'This lead was converted'\n : 'This lead is already closed',\n },\n ]}\n />\n </Box>\n )\n },\n },\n ],\n [roster, routes, writeLead, hostId, mount, campaignName, leadFields.active],\n )\n /* The column and sort models are the view's (AGL-2617). */\n const grid = useCrmViewGrid(views, columns)\n\n return (\n <>\n <CardDisplay\n header={'Leads'}\n help={Aglyn.pluginDocsHelp('contacts', {\n anchor: '#whats-in-the-crm-area',\n })}\n actions={\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n {/* The saved view this list is showing, beside the status it narrows to (AGL-2617). */}\n <CrmViewsControl controller={views} allLabel=\"All leads\" />\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <InputLabel id={filterLabelId}>{'Show'}</InputLabel>\n <Select\n labelId={filterLabelId}\n label=\"Show\"\n value={filter}\n onChange={(event) =>\n setFilter(event.target.value as LeadFilter)\n }\n >\n {LEAD_FILTERS.map((option) => (\n <MenuItem key={option} value={option}>\n {LEAD_FILTER_LABELS[option]}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n {/* The verdict on the address (AGL-3245): the bounced, the blocked, the ones who left. */}\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <InputLabel id={emailFilterLabelId}>{'Email'}</InputLabel>\n <Select\n labelId={emailFilterLabelId}\n label=\"Email\"\n value={emailFilter}\n onChange={(event) =>\n setEmailFilter(event.target.value as LeadEmailFilter)\n }\n >\n {LEAD_EMAIL_FILTERS.map((option) => (\n <MenuItem key={option} value={option}>\n {LEAD_EMAIL_FILTER_LABELS[option]}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n {/* The campaign the lead is filed under (AGL-3254): the site's containers, by name. */}\n {hostId ? (\n <FormControl size=\"small\" sx={{ minWidth: 180 }}>\n <InputLabel id={campaignFilterLabelId} shrink>\n {'Campaign'}\n </InputLabel>\n <Select\n labelId={campaignFilterLabelId}\n label=\"Campaign\"\n notched\n value={campaignFilter}\n onChange={(event) =>\n setCampaignFilter(String(event.target.value))\n }\n displayEmpty\n >\n <MenuItem value=\"\">{'Any campaign'}</MenuItem>\n {campaigns.options.map((option) => (\n <MenuItem key={option.value} value={option.value}>\n {option.label}\n </MenuItem>\n ))}\n {/* A stored filter naming a campaign the site no longer lists stays selectable, by id, so it can be cleared. */}\n {campaignFilter &&\n !campaigns.options.some(\n (option) => option.value === campaignFilter,\n ) ? (\n <MenuItem value={campaignFilter}>{campaignFilter}</MenuItem>\n ) : null}\n </Select>\n </FormControl>\n ) : null}\n <TextField\n size=\"small\"\n value={search}\n onChange={(event) => setSearch(event.target.value)}\n placeholder=\"Search leads\"\n slotProps={{\n input: {\n startAdornment: (\n <InputAdornment position=\"start\">\n <MdiIcon path={mdiMagnify.path} size={0.8} />\n </InputAdornment>\n ),\n },\n htmlInput: { 'aria-label': 'Search leads', type: 'search' },\n }}\n sx={{ minWidth: 200 }}\n />\n <LeadImportButton hostId={hostId} />\n <Button size=\"small\" onClick={handleExport} disabled={!rows.length}>\n {'Export CSV'}\n </Button>\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setCreateOpen(true)}\n >\n {'New lead'}\n </Button>\n </Stack>\n }\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/* Which surfaces file a lead, by name (AGL-2612) — under a site its own, at the org level every site's (AGL-2638). */}\n {hostId ? (\n <LeadSurfacesNote hostId={hostId} />\n ) : (\n <OrgLeadSurfacesNote />\n )}\n {status === 'success' && window.length === 0 ? (\n <EmptyStateComponent\n label={'No leads yet'}\n description={\n 'Bookings and lead-routed forms on your site become leads on their own — or add one with New lead, or bring a list in with Import CSV.'\n }\n />\n ) : status === 'success' && rows.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`No ${LEAD_FILTER_LABELS[filter].toLowerCase()} leads` +\n (search.trim() ? ` match “${search.trim()}”` : '') +\n ` among the ${window.length.toLocaleString()} most recently seen.`}\n </Typography>\n ) : (\n <>\n <LeadsBulkBar\n rows={rows}\n selected={selectedIds}\n onSelectedChange={setSelectedIds}\n roster={roster}\n csv={csvOptions}\n orgId={orgId}\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n />\n <CrmColumnOrderProvider value={grid.columnOrder}>\n <ListTable\n rows={pageRows}\n columns={grid.columns}\n slots={CRM_LIST_SLOTS}\n selectable={{\n selected: selectedIds,\n onChange: setSelectedIds,\n }}\n loading={status === 'loading'}\n onOpen={(_id, row: LeadRow) =>\n router.push(\n routes.lead(row.leadId),\n )\n }\n // Columns and sort are the view's, controlled (AGL-2617).\n columnVisibilityModel={grid.columnVisibilityModel}\n onColumnVisibilityModelChange={\n grid.onColumnVisibilityModelChange\n }\n sortModel={grid.sortModel}\n onSortModelChange={grid.onSortModelChange}\n // The search is the section's, above: the grid's own box\n // would search this page alone.\n quickFilter={false}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n </CrmColumnOrderProvider>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={pageRows.length}\n count={rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`Showing the ${LEADS_WINDOW.toLocaleString()} most recently seen ` +\n 'leads. The search box and the status filter narrow these ' +\n `${LEADS_WINDOW.toLocaleString()} only; older leads are still ` +\n 'listed in the Inbox and reached by campaign audiences.'}\n </Alert>\n ) : null}\n </Stack>\n </CardDisplay>\n <NewLeadDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n hostId={hostId ?? null}\n org={org as Record<string, unknown> | undefined}\n busy={createBusy}\n error={createError}\n roster={roster}\n orgId={orgId}\n onSubmit={(values) => void handleCreate(values)}\n />\n <AssignOwnerDialog\n lead={assigning}\n roster={roster}\n onClose={() => setAssigning(null)}\n onAssign={(uid) => {\n if (!assigning) return\n void writeLead(\n assigning,\n { ownerUid: uid || deleteField() },\n uid ? 'Owner assigned' : 'Owner cleared',\n )\n setAssigning(null)\n }}\n />\n <LeadUnqualifyDialog\n open={Boolean(unqualifying)}\n onClose={() => setUnqualifying(null)}\n hostId={hostId ?? null}\n leadId={unqualifying?.leadId ?? ''}\n leadLabel={String(\n unqualifying?.['name'] || unqualifying?.['email'] || '',\n )}\n />\n {/* The site the conversion is filed as (AGL-2641), which since\n AGL-3275 is the first site that captured this person rather than\n \"the site the row lives under\" — a lead shared by a consent group\n lives under none of them in particular. Under a site the mounted\n one is used, and the two agree for every single-brand org. */}\n <LeadConvertDialog\n open={Boolean(converting)}\n onClose={() => setConverting(null)}\n hostId={\n hostId ??\n (Aglyn.leadPrimaryGroup(converting, org as Record<string, unknown>).hostId || null)\n }\n orgId={orgId}\n org={org as Record<string, unknown> | undefined}\n leadId={converting?.leadId ?? ''}\n lead={converting ?? {}}\n basePath={basePath ?? ''}\n roster={roster}\n />\n </>\n )\n}\nCrmLeadsSection.displayName = 'CrmLeadsSection'\n\n/**\n * The status, editable in place for a lead that is still open.\n *\n * A converted lead shows the chip alone: its status IS the conversion, and\n * the route stamped it. The select stops its click from reaching the row, or\n * every status change would also open the record.\n */\nfunction InlineStatus(props: {\n lead: LeadRow\n onChange: (next: CrmLeadStatus) => void\n}) {\n const { lead, onChange } = props\n if (lead.convertedContactId) {\n return (\n <Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>\n <LeadStatusChip lead={lead} />\n </Box>\n )\n }\n const status = Aglyn.crmLeadStatus(lead)\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{\n display: 'flex',\n alignItems: 'center',\n height: '100%',\n width: '100%',\n }}\n >\n <Select\n size=\"small\"\n variant=\"standard\"\n disableUnderline\n value={status}\n onChange={(event) => onChange(event.target.value as CrmLeadStatus)}\n renderValue={() => <LeadStatusChip lead={lead} />}\n sx={{ width: '100%' }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">\n {Aglyn.CRM_LEAD_STATUS_LABELS.working}\n </MenuItem>\n <MenuItem value=\"unqualified\">{`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}</MenuItem>\n </Select>\n </Box>\n )\n}\nInlineStatus.displayName = 'InlineStatus'\n\n/** Hand a lead to a team member. */\nfunction AssignOwnerDialog(props: {\n lead: LeadRow | null\n roster: OrgMemberOptions\n onClose: () => void\n onAssign: (uid: string) => void\n}) {\n const { lead, roster, onClose, onAssign } = props\n const [uid, setUid] = useState<string | null>(null)\n const current = uid ?? String(lead?.ownerUid ?? '')\n return (\n <Dialog\n open={Boolean(lead)}\n onClose={onClose}\n maxWidth=\"xs\"\n fullWidth\n slotProps={{ transition: { onExited: () => setUid(null) } }}\n >\n <DialogTitle>{`Assign ${String(lead?.['name'] || lead?.['email'] || 'lead')}`}</DialogTitle>\n <DialogContent>\n <Box sx={{ pt: 1 }}>\n <LeadOwnerSelect\n value={current}\n onChange={setUid}\n roster={roster}\n size=\"medium\"\n />\n </Box>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose}>{'Cancel'}</Button>\n <Button variant=\"contained\" onClick={() => onAssign(current)}>\n {'Assign'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nAssignOwnerDialog.displayName = 'AssignOwnerDialog'\n\nexport default CrmLeadsSection\n"],"names":["Aglyn","mdiAccountArrowRight","mdiAccountCancelOutline","mdiAccountConvertOutline","mdiAccountTieOutline","mdiMagnify","CardDisplay","MdiIcon","ListPagination","ListTable","useOrgMemberOptions","useCrmOrgMount","useCrmSavedView","useCrmScope","scopeTokensForHost","useContactFieldDefinitions","customFieldColumns","useCrmViewGrid","CRM_LIST_SLOTS","CrmColumnOrderProvider","useOrgLeads","CrmViewsControl","RowActionsMenu","TABLE_PAGE_SIZE_DEFAULT","EmptyStateComponent","useSnackbar","useFirestore","useFirestoreCollection","useHostCampaigns","Alert","Box","Button","Dialog","DialogActions","DialogContent","DialogTitle","FormControl","InputAdornment","InputLabel","MenuItem","Select","Stack","TextField","Typography","collection","deleteField","doc","limit","orderBy","query","serverTimestamp","updateDoc","where","useRouter","useCallback","useEffect","useId","useMemo","useState","downloadTextFile","crmRoutes","LEAD_FILTER_LABELS","LEAD_EMAIL_FILTER_LABELS","LEAD_EMAIL_FILTERS","LEAD_FILTERS","leadMatchesCampaignFilter","leadMatchesEmailFilter","leadMatchesFilter","leadMatchesSearch","leadsCsv","LeadConvertDialog","leadSourceLabel","leadSources","leadTimeLabel","LeadImportButton","NewLeadDrawer","useCrmApi","LeadOwnerSelect","CONVERT_PENDING_ERASURE_REASON","CrmEmailStateChip","LeadStatusChip","LeadSurfacesNote","LeadUnqualifyDialog","LeadsBulkBar","OrgLeadSurfacesNote","LEADS_WINDOW","CrmLeadsSection","props","hostId","org","basePath","firestore","router","enqueueSnackbar","orgId","createHostId","mount","roster","leadFields","routes","site","idField","orgHostIds","hosts","map","host","id","orgLeads","visibleTo","windowSize","leadDocs","data","row","leadId","$id","status","hostsReady","length","truncated","window","slice","views","section","filter","value","state","filters","find","clause","field","includes","emailFilter","setFilter","next","setFilters","op","setEmailFilter","campaignFilter","setCampaignFilter","campaigns","enabled","Boolean","campaignName","options","option","label","filterLabelId","emailFilterLabelId","campaignFilterLabelId","search","setSearch","rows","lead","page","setPage","pageSize","setPageSize","pageRows","selectedIds","setSelectedIds","csvOptions","ownerEmail","emailFor","siteName","handleExport","assigning","setAssigning","unqualifying","setUnqualifying","converting","setConverting","crmApi","createOpen","setCreateOpen","createBusy","setCreateBusy","createError","setCreateError","handleCreate","values","response","payload","email","name","company","jobTitle","phone","website","leadSource","address","tags","campaignIds","ownerUid","notes","custom","ok","String","variant","persist","error","console","writeLead","fields","done","updatedAt","Error","message","columns","headerName","flex","minWidth","valueGetter","_value","renderCell","spacing","sx","justifyContent","height","noWrap","color","crmLeadStatus","InlineStatus","onChange","unqualifiedReason","readEmailState","EMAIL_STATE_LABELS","labelFor","held","Array","isArray","join","readCampaignIds","active","width","sortable","filterable","disableColumnMenu","converted","convertedContactId","erasurePending","readErasureRequestedAtMs","convertRefusal","isCrmLeadOpen","onClick","event","stopPropagation","display","alignItems","items","key","icon","path","size","href","disabled","disabledReason","undefined","grid","header","help","pluginDocsHelp","anchor","actions","direction","controller","allLabel","labelId","target","shrink","notched","displayEmpty","some","placeholder","slotProps","input","startAdornment","position","htmlInput","type","contentGutterX","contentGutterY","description","toLowerCase","trim","toLocaleString","selected","onSelectedChange","csv","columnOrder","slots","selectable","loading","onOpen","_id","push","columnVisibilityModel","onColumnVisibilityModelChange","sortModel","onSortModelChange","quickFilter","hideFooter","rowCount","count","onPageChange","onPageSizeChange","severity","open","onClose","busy","onSubmit","AssignOwnerDialog","onAssign","uid","leadLabel","leadPrimaryGroup","displayName","disableUnderline","renderValue","CRM_LEAD_STATUS_LABELS","new","working","unqualified","setUid","current","maxWidth","fullWidth","transition","onExited","pt"],"mappings":"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"}
@@ -254,14 +254,9 @@ import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.
254
254
  });
255
255
  }
256
256
  /*
257
- * THE ORG ROW, AND EVERY LEGACY ROW STILL STANDING.
258
- *
259
- * An erasure may not be the one caller that trusts the migration to be
260
- * finished: until AGL-3276 has folded every site and AGL-3277 has deleted
261
- * the fallback, a person can still be held at `hosts/{siteId}/leads`, and
262
- * a marker that reached only the org row would leave that copy unmarked.
263
- * So this marks whatever exists, in both homes, and the sweep costs one
264
- * read per site exactly as it did before.
257
+ * THE ORG ROW. One document for the person, since AGL-3276 emptied the
258
+ * host path and AGL-3277 removed it — the per-site sweep this used to do
259
+ * beside it had nothing left to find.
265
260
  */ const eraseLeadAt = async (ref, where)=>{
266
261
  await ref.get().then((snapshot)=>snapshot.exists ? ref.update({
267
262
  [CONTACT_ERASURE_REQUESTED_FIELD]: now
@@ -270,9 +265,6 @@ import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.
270
265
  });
271
266
  };
272
267
  await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId);
273
- for (const siteId of hostIds){
274
- await eraseLeadAt(firestore.collection('hosts').doc(siteId).collection('leads').doc(key), siteId);
275
- }
276
268
  try {
277
269
  const contacts = await firestore.collection('orgs').doc(orgId).collection('contacts').where('email', '==', recordEmail).get();
278
270
  for (const contact of contacts.docs){
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n canManageOrg,\n CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n orgDataCollectionForHost,\n readLeadForHost,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A workspace admin or owner — `canManageOrg` on the caller's org role —\n * and nobody else. Not a site editor with the data permission, who can\n * detach a contact from their own site but has no standing to remove the\n * person from every site in the workspace; and not staff acting alone,\n * because the workspace is the controller of this data and the instruction\n * has to come from it. Staff who are also admins of the workspace pass on\n * that role, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Only a workspace admin can erase a person from the workspace'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'manage-org',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!canManageOrg(membership.role)) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await readLeadForHost(hostId, leadId)\n if (!lead) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n /*\n * Suppression stays PER SITE — it is that site's sending list — while the\n * lead marker below is written once, because the lead is one org row now\n * (AGL-3275) rather than one row per site.\n */\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n }\n /*\n * THE ORG ROW, AND EVERY LEGACY ROW STILL STANDING.\n *\n * An erasure may not be the one caller that trusts the migration to be\n * finished: until AGL-3276 has folded every site and AGL-3277 has deleted\n * the fallback, a person can still be held at `hosts/{siteId}/leads`, and\n * a marker that reached only the org row would leave that copy unmarked.\n * So this marks whatever exists, in both homes, and the sweep costs one\n * read per site exactly as it did before.\n */\n const eraseLeadAt = async (\n ref: FirebaseFirestore.DocumentReference,\n where: string,\n ) => {\n await ref\n .get()\n .then((snapshot) =>\n snapshot.exists\n ? ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', where, error)\n })\n }\n await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId)\n for (const siteId of hostIds) {\n await eraseLeadAt(\n firestore.collection('hosts').doc(siteId).collection('leads').doc(key),\n siteId,\n )\n }\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["canManageOrg","CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","orgDataCollectionForHost","readLeadForHost","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","role","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","eraseLeadAt","ref","where","then","snapshot","update","contacts","docs","target","type","id","add","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n canManageOrg,\n CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n orgDataCollectionForHost,\n readLeadForHost,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A workspace admin or owner — `canManageOrg` on the caller's org role —\n * and nobody else. Not a site editor with the data permission, who can\n * detach a contact from their own site but has no standing to remove the\n * person from every site in the workspace; and not staff acting alone,\n * because the workspace is the controller of this data and the instruction\n * has to come from it. Staff who are also admins of the workspace pass on\n * that role, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Only a workspace admin can erase a person from the workspace'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'manage-org',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!canManageOrg(membership.role)) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await readLeadForHost(hostId, leadId)\n if (!lead) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n /*\n * Suppression stays PER SITE — it is that site's sending list — while the\n * lead marker below is written once, because the lead is one org row now\n * (AGL-3275) rather than one row per site.\n */\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n }\n /*\n * THE ORG ROW. One document for the person, since AGL-3276 emptied the\n * host path and AGL-3277 removed it — the per-site sweep this used to do\n * beside it had nothing left to find.\n */\n const eraseLeadAt = async (\n ref: FirebaseFirestore.DocumentReference,\n where: string,\n ) => {\n await ref\n .get()\n .then((snapshot) =>\n snapshot.exists\n ? ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', where, error)\n })\n }\n await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId)\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["canManageOrg","CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","orgDataCollectionForHost","readLeadForHost","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","role","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","eraseLeadAt","ref","where","then","snapshot","update","contacts","docs","target","type","id","add","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
@@ -21,10 +21,6 @@ import { BUNDLE_ID } from "../constants/bundle-common.js";
21
21
  const NONE = {
22
22
  records: 0
23
23
  };
24
- /** The sites an organization owns, by id: `hosts` where `orgId` is the org's. */ async function orgHostIds(firestore, orgId) {
25
- const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).select().get();
26
- return hosts.docs.map((doc)=>doc.id);
27
- }
28
24
  /**
29
25
  * Applies the verdict to one record; answers whether it moved. A read then
30
26
  * a merge rather than a transaction: two verdicts landing together each
@@ -92,19 +88,13 @@ export function createCrmRecordEmailStateWriter(deps) {
92
88
  }
93
89
  try {
94
90
  /*
95
- * ONE ORG ROW, plus any legacy site row the backfill has not reached
96
- * (AGL-3275). A bounce or a do-not-contact mark is the platform's
97
- * verdict on the ADDRESS, so it has to reach every copy that still
98
- * exists an unmarked leftover is a person who asked not to be
99
- * mailed and could be. AGL-3277 drops the second loop with the
100
- * fallback.
91
+ * ONE ORG ROW. A bounce or a do-not-contact mark is the platform's
92
+ * verdict on the ADDRESS, and since AGL-3277 there is one document
93
+ * carrying it the per-site loop beside this had nothing left to
94
+ * find once AGL-3276 emptied the host path.
101
95
  */ if (await stampRecord(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), state, force)) {
102
96
  records += 1;
103
97
  }
104
- for (const hostId of (await orgHostIds(firestore, orgId))){
105
- const lead = firestore.collection('hosts').doc(hostId).collection('leads').doc(key);
106
- if (await stampRecord(lead, state, force)) records += 1;
107
- }
108
98
  } catch (error) {
109
99
  console.error('[crm] the leads could not be stamped with an email state', orgId, error);
110
100
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/** The sites an organization owns, by id: `hosts` where `orgId` is the org's. */\nasync function orgHostIds(firestore: Firestore, orgId: string): Promise<string[]> {\n const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).select().get()\n return hosts.docs.map((doc) => doc.id)\n}\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n await ref.set({ [EMAIL_STATE_FIELD]: next }, { merge: true })\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n /*\n * ONE ORG ROW, plus any legacy site row the backfill has not reached\n * (AGL-3275). A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, so it has to reach every copy that still\n * exists — an unmarked leftover is a person who asked not to be\n * mailed and could be. AGL-3277 drops the second loop with the\n * fallback.\n */\n if (await stampRecord(\n firestore.collection('orgs').doc(orgId).collection('leads').doc(key),\n state,\n force,\n )) {\n records += 1\n }\n for (const hostId of await orgHostIds(firestore, orgId)) {\n const lead = firestore.collection('hosts').doc(hostId).collection('leads').doc(key)\n if (await stampRecord(lead, state, force)) records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","BUNDLE_ID","NONE","records","orgHostIds","firestore","orgId","hosts","collection","where","select","get","docs","map","doc","id","stampRecord","ref","incoming","force","snapshot","exists","current","data","next","status","atMs","source","detail","set","merge","resolveOrgId","request","host","named","String","trim","hostId","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","lead","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n await ref.set({ [EMAIL_STATE_FIELD]: next }, { merge: true })\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n /*\n * ONE ORG ROW. A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, and since AGL-3277 there is one document\n * carrying it — the per-site loop beside this had nothing left to\n * find once AGL-3276 emptied the host path.\n */\n if (await stampRecord(\n firestore.collection('orgs').doc(orgId).collection('leads').doc(key),\n state,\n force,\n )) {\n records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","BUNDLE_ID","NONE","records","stampRecord","ref","incoming","force","snapshot","get","exists","current","data","next","status","atMs","source","detail","set","merge","resolveOrgId","firestore","request","host","named","String","orgId","trim","hostId","collection","doc","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}