@aglyn/plugins-inbox 1.0.0-beta.195 → 1.0.0-beta.197

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-inbox",
3
- "version": "1.0.0-beta.195",
3
+ "version": "1.0.0-beta.197",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,15 +25,15 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.195",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.195",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.195",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.195",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.195",
33
- "@aglyn/shared-util-email": "1.0.0-beta.195",
34
- "@aglyn/shared-util-http": "1.0.0-beta.195",
35
- "@aglyn/tenant-data-admin": "1.0.0-beta.195",
36
- "@aglyn/tenant-feature-instance": "1.0.0-beta.195",
28
+ "@aglyn/aglyn": "1.0.0-beta.197",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.197",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.197",
31
+ "@aglyn/shared-ui-next": "1.0.0-beta.197",
32
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.197",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.197",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.197",
35
+ "@aglyn/tenant-data-admin": "1.0.0-beta.197",
36
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.197",
37
37
  "@swc/helpers": "0.5.23"
38
38
  },
39
39
  "peerDependencies": {
@@ -442,8 +442,8 @@ const CONTACT_SEARCH_FIELDS = [
442
442
  children: [
443
443
  /*#__PURE__*/ _jsxs(CardDisplay, {
444
444
  header: hostId == null ? 'Leads' : 'Site Members & Leads',
445
- help: pluginDocsHelp('membersOnly', {
446
- anchor: '#manage-your-members'
445
+ help: pluginDocsHelp('forms', {
446
+ anchor: '#filter-the-inbox'
447
447
  }),
448
448
  contentGutterX: true,
449
449
  contentGutterY: true,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/contacts-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n normalizeContactEmail,\n pluginDocsHelp,\n type ConsolePluginOrgMount,\n} from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { useListRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-rows-filter'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, limit, orderBy, query, where } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { orgSiteNames } from './inbox-org-sites'\n\n/**\n * How many members and how many leads the contacts table reads.\n *\n * A ceiling rather than a page size — see the two queries, which explain why\n * this one table cannot be paged by the server without breaking the dedupe\n * between them.\n */\nconst CONTACT_CEILING = 200\n\n/*\n * What the contacts grid's Filters panel offers (AGL-3317). The card holds\n * both capped windows, so it answers the panel over every contact they read;\n * the notice under the table says when a cap truncated one. Type reads\n * `typeKey`: `member`, or `lead` with the lead's `source` when it has one,\n * which is what the column draws. On the organization's Inbox, Site matches\n * any site that captured the lead.\n */\nconst CONTACT_FILTER_FIELDS = [\n inMemoryListField('email', 'text'),\n inMemoryListField('contactKind', 'select', 'typeKey'),\n]\nconst ORG_CONTACT_FILTER_FIELDS = [\n ...CONTACT_FILTER_FIELDS,\n {\n ...inMemoryListField('capturedByHostIds', 'select'),\n tokensPath: 'capturedByHostIds',\n verbatimTokens: true,\n },\n]\nconst CONTACT_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Email',\n contactKind: 'Type',\n capturedByHostIds: 'Site',\n}\nconst CONTACT_SEARCH_FIELDS = ['email', 'displayName', 'name', 'source'] as const\n\n/** How a contact's type reads, from its `typeKey`. */\nconst contactTypeLabel = (typeKey: string): string =>\n typeKey === 'member'\n ? 'Member'\n : typeKey.startsWith('lead:')\n ? `Lead · ${typeKey.slice('lead:'.length)}`\n : 'Lead'\n\n/**\n * The Members & leads section of the Inbox (AGL-109): everybody a site\n * collected, in one list.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n *\n * On the organization's Inbox (`hostId: null`, AGL-3303) it lists the\n * organization's leads with the sites each was captured by. A member signs up\n * to ONE site and lives under it, so members are listed once a site is\n * picked, which is the page handing this card that site.\n */\nexport function ContactsCard({\n hostId,\n orgMount,\n}: {\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*==========================================\n * SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.\n *\n * Both reads were `limit(200)` with no `orderBy` and a client sort on top —\n * the same document-id sample the submissions list used to take, and both\n * now name the order the rows are rendered in. `createdAt` is safe on both:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction (every other membership path\n * updates an existing document), `recordVisitorLead` is the only writer\n * that creates a lead and stamps it on every `tx.create`, and neither\n * collection is in `IMPORTABLE_FIELDS`.\n *\n * What they must NOT do is page the query, and the reason is the dedupe\n * below: a lead is hidden when a MEMBER already exists on the same address.\n * That test is only correct while both windows are whole. On a ten-row\n * server page it would compare a page of leads against a page of members, so\n * somebody who signed up after leaving their address would render as a\n * Member on one page and again as a Lead on another — one person counted\n * twice, in a list a site owner uses to count people.\n *\n * So the CEILING stays and the page is a slice of the assembled rows. The\n * probe row makes \"there are more contacts than these\" a fact rather than a\n * guess from `length === CONTACT_CEILING`, which is wrong at exactly the\n * count that equals the ceiling.\n *=========================================*/\n const { data: memberDocs } = useFirestoreCollection<any>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n /*\n * The lead silo is the org's (AGL-3275), narrowed to this site by\n * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its\n * pre-migration rows and nothing captured since.\n */\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: hostId == null ? orgMount?.orgId : undefined,\n })\n const siteMembers = (memberDocs ?? []).slice(0, CONTACT_CEILING)\n const { data: leadDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(\n collection(firestore, 'orgs', orgId, 'leads'),\n /*\n * Every site's, on the organization's Inbox: an org-wide member\n * reads the collection unscoped — the rules short-circuit on\n * their reach, as for the CRM's own org list — so a clause here\n * would only narrow what they may already read.\n */\n ...(hostId\n ? [\n where(\n 'visibleTo',\n 'array-contains-any',\n scopeTokensForHost(hostId),\n ),\n ]\n : []),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const leads = (leadDocs ?? []).slice(0, CONTACT_CEILING)\n const contactsTruncated =\n (memberDocs?.length ?? 0) > CONTACT_CEILING ||\n (leadDocs?.length ?? 0) > CONTACT_CEILING\n /*\n * One person renders once, whichever way they came in.\n *\n * Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so\n * somebody could appear as a Member on one row and a Lead on another. The\n * comparison is now the same NORMALIZATION a lead document is keyed by —\n * `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an\n * already-normalized address cannot merge or split anything the normalizer\n * did not, so the two agree by construction.\n *\n * `personKey` itself cannot run here: it needs `node:crypto` and this is a\n * client component. That is why the shared half is the normalizer rather\n * than the digest.\n */\n const dedupedLeads = useMemo(() => {\n const memberKeys = new Set(\n siteMembers\n .map((member: any) => normalizeContactEmail(member.email))\n .filter(Boolean),\n )\n return leads.filter(\n (lead: any) => !memberKeys.has(normalizeContactEmail(lead.email)),\n )\n }, [leads, siteMembers])\n /*\n * ONE list of two collections — members first, then the leads that are not\n * already members — so the grid pages the concatenation rather than either\n * read, and a page stays whole across the seam between them. Both reads are\n * ceilinged and complete below the ceiling, so the grid's count is the whole\n * deduped list.\n */\n const contacts = useMemo(\n () => [\n ...siteMembers.map((member: any) => ({\n ...member,\n contactKind: 'member',\n typeKey: 'member',\n })),\n ...dedupedLeads.map((lead: any) => ({\n ...lead,\n contactKind: 'lead',\n typeKey: lead.source ? `lead:${lead.source}` : 'lead',\n })),\n ],\n [siteMembers, dedupedLeads],\n )\n /*\n * A lead's source is open-ended, so the Type choices are the ones the rows\n * hold, member and plain lead first.\n */\n const contactFilterOptions = useMemo(() => {\n const leadSources = [\n ...new Set(\n contacts\n .map((contact: any) => contact.typeKey as string)\n .filter((typeKey) => typeKey.startsWith('lead:')),\n ),\n ].sort()\n return {\n contactKind: ['member', 'lead', ...leadSources].map((value) => ({\n value,\n label: contactTypeLabel(value),\n })),\n ...(hostId == null\n ? {\n capturedByHostIds: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {}),\n }\n }, [contacts, hostId, orgMount])\n const contactFilter = useListRowsFilter({\n rows: contacts,\n fields: hostId == null ? ORG_CONTACT_FILTER_FIELDS : CONTACT_FILTER_FIELDS,\n options: contactFilterOptions,\n headers: CONTACT_FILTER_HEADERS,\n search: CONTACT_SEARCH_FIELDS,\n })\n /*\n * REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a\n * client delete. A member's password hash lives in a document no client can\n * reach, so deleting the profile from here would leave it behind: the route\n * deletes both, and the rules refuse a client delete of any member that\n * has one.\n */\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n // Members are listed only under a site, so there is one to delete from.\n if (!hostId) return\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const response = await authorizedFetch(user, '/api/membership/admin-remove', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, memberId: member.$id }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n } catch {\n enqueueSnackbar('The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n },\n [confirm, user, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n /*\n * The site a lead's attribution is asked of. Under a site it is that site;\n * across every site a lead is one org row that several sites may have met,\n * and the credit is filed by the site whose capture made it — the first in\n * `capturedByHostIds`, the site the CRM's own lead page asks as well.\n */\n const originSite = (contact: any): string | null => {\n if (hostId) return hostId\n const first = Array.isArray(contact?.capturedByHostIds)\n ? contact.capturedByHostIds[0]\n : null\n return typeof first === 'string' && first ? first : null\n }\n const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null\n\n const contactActions = (contact: any): RowActionsMenuItem[] =>\n contact.contactKind === 'member'\n ? [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(contact)(),\n },\n ]\n : [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(contact)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(contact) as string,\n },\n ]\n : []),\n // A lead no site captured has no site to ask for its campaign.\n ...(originSite(contact)\n ? [\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(contact),\n },\n ]\n : []),\n ]\n const contactColumns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name =\n contact.contactKind === 'member' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n /*\n WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both\n lead writers — `'signup'` and `'booking'` — since AGL-109, so a site\n owner can tell a membership sign-up from a booking. Falls back to the\n bare label for a row written before the field, or by a future writer\n that omits it.\n */\n field: 'contactKind',\n headerName: 'Type',\n width: 170,\n valueGetter: (_value, contact) => contact.typeKey,\n renderCell: ({ row: contact }) =>\n contact.contactKind === 'member' ? (\n <Chip label={contactTypeLabel(contact.typeKey)} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={contactTypeLabel(contact.typeKey)} size=\"small\" variant=\"outlined\" />\n ),\n },\n /*\n KNOWN BY, on the organization's Inbox: every site that captured the\n person, as the CRM's organization Leads list names them. A lead is one\n org row, so \"which site\" is a set, never one answer.\n */\n ...(hostId == null\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Site',\n flex: 1,\n minWidth: 160,\n // Sorted and drawn by name; the filter matches the ids.\n valueGetter: (_value: unknown, contact: any) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n renderCell: ({ row: contact }: { row: any }) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'createdAt',\n headerName: 'Joined',\n width: 200,\n // Sorted on the instant, drawn as a local string.\n valueGetter: (_value, contact) => contact.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={contactActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <>\n <CardDisplay\n header={hostId == null ? 'Leads' : 'Site Members & Leads'}\n help={pluginDocsHelp('membersOnly', {\n anchor: '#manage-your-members',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: leads.length ? 2 : 1 }}\n >\n {'Every site’s leads, newest first. Members sign up to one site ' +\n 'each — choose a site to list its members beside its leads.'}\n </Typography>\n ) : null}\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {hostId == null\n ? 'No leads on any site yet.'\n : 'No members yet — visitors can join at /signup on your ' +\n 'site; sign-ups also appear here as leads.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips {...contactFilter.chipsProps} />\n <ListTable\n aria-label=\"Site members and leads\"\n rows={contactFilter.rows}\n columns={contactFilter.filterColumns(contactColumns)}\n // The panel and the search are the grid's; the card answers\n // them over every contact both windows read.\n {...contactFilter.gridProps}\n noRowsLabel=\"No contacts match these filters\"\n // A member and a lead are two collections, so an id alone could\n // name one of each.\n getRowId={(contact: any) => `${contact.contactKind}:${contact.$id}`}\n rowHeight={TABLE_ROW_HEIGHT}\n />\n {contactsTruncated ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {hostId == null\n ? `Paging the ${CONTACT_CEILING} newest leads. Your ` +\n 'sites hold more than that — the CRM’s Leads list ' +\n 'reaches every one of them.'\n : `Paging the ${CONTACT_CEILING} newest members and the ` +\n `${CONTACT_CEILING} newest leads. This site has more ` +\n 'than that — the campaign audiences still reach ' +\n 'everyone, whether or not they are listed here.'}\n </Alert>\n ) : null}\n </>\n )}\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id && leadOriginSite ? (\n <InboxRecordAttributionZone\n hostId={leadOriginSite}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["normalizeContactEmail","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListRowActions","ListTable","listActionsColumn","inMemoryListField","useListRowsFilter","TABLE_ROW_HEIGHT","useSnackbar","authorizedFetch","useFirestore","useFirestoreCollection","useOrgDataScope","useUser","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","limit","orderBy","query","where","useCallback","useMemo","useState","useRecordRouteContext","scopeTokensForHost","orgSiteNames","CONTACT_CEILING","CONTACT_FILTER_FIELDS","ORG_CONTACT_FILTER_FIELDS","tokensPath","verbatimTokens","CONTACT_FILTER_HEADERS","email","contactKind","capturedByHostIds","CONTACT_SEARCH_FIELDS","contactTypeLabel","typeKey","startsWith","slice","length","ContactsCard","hostId","orgMount","firestore","data","user","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","memberDocs","idField","orgId","undefined","siteMembers","leadDocs","leads","contactsTruncated","dedupedLeads","memberKeys","Set","map","member","filter","Boolean","lead","has","contacts","source","contactFilterOptions","leadSources","sort","value","label","hosts","host","id","name","contactFilter","rows","fields","options","headers","search","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","response","method","body","JSON","stringify","memberId","payload","json","ok","error","variant","allowDuplicate","persist","leadOrigin","setLeadOrigin","originSite","first","Array","isArray","leadOriginSite","contactActions","key","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","mb","chipsProps","aria-label","columns","filterColumns","gridProps","noRowsLabel","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SACEA,qBAAqB,EACrBC,cAAc,QAET,eAAc;AACrB,2EAA2E;AAC3E,0EAA0E;AAC1E,6EAA6E;AAC7E,wEAAwE;AACxE,SAASC,0BAA0B,QAAQ,8BAA0B;AACrE,8EAA8E;AAC9E,8EAA8E;AAC9E,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SACEC,oBAAoB,EACpBC,uBAAuB,EACvBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,WAAW,EAAEC,OAAO,EAAEC,sBAAsB,QAAQ,uBAAsB;AACnF,OAAOC,qBAAqB,8DAA6D;AACzF,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAC7D,SAASC,iBAAiB,QAAQ,8CAA6C;AAC/E,SAASC,iBAAiB,QAAQ,kDAAiD;AAEnF,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SACEC,YAAY,EACZC,sBAAsB,EACtBC,eAAe,EACfC,OAAO,QACF,iCAAgC;AACvC,SACEC,KAAK,EACLC,MAAM,EACNC,IAAI,EACJC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,WAAW,EACXC,KAAK,EACLC,UAAU,QACL,gBAAe;AAEtB,SAASC,UAAU,EAAEC,KAAK,EAAEC,OAAO,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7E,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,qBAAqB,QAAQ,gCAA4B;AAClE,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,YAAY,QAAQ,uBAAmB;AAEhD;;;;;;CAMC,GACD,MAAMC,kBAAkB;AAExB;;;;;;;CAOC,GACD,MAAMC,wBAAwB;IAC5B9B,kBAAkB,SAAS;IAC3BA,kBAAkB,eAAe,UAAU;CAC5C;AACD,MAAM+B,4BAA4B;OAC7BD;IACH,aACK9B,kBAAkB,qBAAqB;QAC1CgC,YAAY;QACZC,gBAAgB;;CAEnB;AACD,MAAMC,yBAA2D;IAC/DC,OAAO;IACPC,aAAa;IACbC,mBAAmB;AACrB;AACA,MAAMC,wBAAwB;IAAC;IAAS;IAAe;IAAQ;CAAS;AAExE,oDAAoD,GACpD,MAAMC,mBAAmB,CAACC,UACxBA,YAAY,WACR,WACAA,QAAQC,UAAU,CAAC,WACjB,CAAC,OAAO,EAAED,QAAQE,KAAK,CAAC,QAAQC,MAAM,GAAG,GACzC;AAER;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,aAAa,EAC3BC,MAAM,EACNC,QAAQ,EAKT;;IACC,MAAMC,YAAY1C;IAClB,MAAM,EAAE2C,MAAMC,IAAI,EAAE,GAAGzC;IACvB,MAAM,EAAE0C,eAAe,EAAE,GAAG/C;IAC5B,MAAM,EAAEgD,OAAO,EAAE,GAAGxD;IACpB,sEAAsE;IACtE,oEAAoE;IACpE,MAAMyD,eAAe1B;IACrB,MAAM2B,WAAW,CAACC,UAChBF,eACI/D,iBAAiB,QAAQ+D,cAAcG,OAAOD,QAAQE,GAAG,KACzD;IAEN;;;;;;;;;;;;;;;;;;;;;;;;6CAwB2C,GAC3C,MAAM,EAAER,MAAMS,UAAU,EAAE,GAAGnD,uBAC3B,IACEuC,SACIxB,MACEH,WAAW6B,WAAW,SAASF,QAAQ,gBACvCzB,QAAQ,aAAa,SACrBD,MAAMU,kBAAkB,MAE1B,MACN;QAACkB;QAAWF;KAAO,EACnB;QAAEa,SAAS;IAAM;IAEnB;;;;GAIC,GACD,MAAM,EAAEC,KAAK,EAAE,GAAGpD,gBAAgB;QAChCsC,MAAM,EAAEA,iBAAAA,SAAUe;QAClBD,OAAOd,UAAU,OAAOC,4BAAAA,SAAUa,KAAK,GAAGC;IAC5C;IACA,MAAMC,cAAc,CAACJ,qBAAAA,aAAc,EAAE,EAAEf,KAAK,CAAC,GAAGb;IAChD,MAAM,EAAEmB,MAAMc,QAAQ,EAAE,GAAGxD,uBACzB,IACEqD,QACItC,MACEH,WAAW6B,WAAW,QAAQY,OAAO,UACrC;;;;;aAKC,MACGd,SACA;YACEvB,MACE,aACA,sBACAK,mBAAmBkB;SAEtB,GACD,EAAE,EACNzB,QAAQ,aAAa,SACrBD,MAAMU,kBAAkB,MAE1B,MACN;QAACkB;QAAWY;QAAOd;KAAO,EAC1B;QAAEa,SAAS;IAAM;IAEnB,MAAMK,QAAQ,CAACD,mBAAAA,WAAY,EAAE,EAAEpB,KAAK,CAAC,GAAGb;IACxC,MAAMmC,oBACJ,SAACP,8BAAAA,WAAYd,MAAM,mBAAI,KAAKd,mBAC5B,UAACiC,4BAAAA,SAAUnB,MAAM,oBAAI,KAAKd;IAC5B;;;;;;;;;;;;;GAaC,GACD,MAAMoC,eAAezC,QAAQ;QAC3B,MAAM0C,aAAa,IAAIC,IACrBN,YACGO,GAAG,CAAC,CAACC,SAAgBnF,sBAAsBmF,OAAOlC,KAAK,GACvDmC,MAAM,CAACC;QAEZ,OAAOR,MAAMO,MAAM,CACjB,CAACE,OAAc,CAACN,WAAWO,GAAG,CAACvF,sBAAsBsF,KAAKrC,KAAK;IAEnE,GAAG;QAAC4B;QAAOF;KAAY;IACvB;;;;;;GAMC,GACD,MAAMa,WAAWlD,QACf,IAAM;eACDqC,YAAYO,GAAG,CAAC,CAACC,SAAiB,aAChCA;oBACHjC,aAAa;oBACbI,SAAS;;eAERyB,aAAaG,GAAG,CAAC,CAACI,OAAe,aAC/BA;oBACHpC,aAAa;oBACbI,SAASgC,KAAKG,MAAM,GAAG,CAAC,KAAK,EAAEH,KAAKG,MAAM,EAAE,GAAG;;SAElD,EACD;QAACd;QAAaI;KAAa;IAE7B;;;GAGC,GACD,MAAMW,uBAAuBpD,QAAQ;;QACnC,MAAMqD,cAAc;eACf,IAAIV,IACLO,SACGN,GAAG,CAAC,CAACd,UAAiBA,QAAQd,OAAO,EACrC8B,MAAM,CAAC,CAAC9B,UAAYA,QAAQC,UAAU,CAAC;SAE7C,CAACqC,IAAI;QACN,OAAO;YACL1C,aAAa;gBAAC;gBAAU;mBAAWyC;aAAY,CAACT,GAAG,CAAC,CAACW,QAAW,CAAA;oBAC9DA;oBACAC,OAAOzC,iBAAiBwC;gBAC1B,CAAA;WACIlC,UAAU,OACV;YACER,mBAAmB,SAACS,4BAAAA,SAAUmC,KAAK,mBAAI,EAAE,EAAEb,GAAG,CAAC,CAACc,OAAU,CAAA;oBACxDH,OAAOG,KAAKC,EAAE;oBACdH,OAAOE,KAAKE,IAAI,IAAIF,KAAKC,EAAE;gBAC7B,CAAA;QACF,IACA,CAAC;IAET,GAAG;QAACT;QAAU7B;QAAQC;KAAS;IAC/B,MAAMuC,gBAAgBpF,kBAAkB;QACtCqF,MAAMZ;QACNa,QAAQ1C,UAAU,OAAOd,4BAA4BD;QACrD0D,SAASZ;QACTa,SAASvD;QACTwD,QAAQpD;IACV;IACA;;;;;;GAMC,GACD,MAAMqD,qBAAqBpE,YACzB,CAAC8C,SAAgB;YACf,wEAAwE;YACxE,IAAI,CAACxB,QAAQ;YACb,MAAM+C,YAAY,MAAMzC,QAAQ;gBAC9B0C,OAAO;gBACPC,aAAa,CAAC,CAAC,EAAEzB,OAAOlC,KAAK,CAAC,qCAAqC,CAAC;gBACpE4D,kBAAkB;gBAClBC,yBAAyB;oBAAEC,OAAO;gBAAQ;YAC5C,GACGC,IAAI,CAAC,IAAM,MACXC,KAAK,CAAC,IAAM;YACf,IAAI,CAACP,WAAW;YAChB,IAAI;gBACF,MAAMQ,WAAW,MAAMhG,gBAAgB6C,MAAM,gCAAgC;oBAC3EoD,QAAQ;oBACRZ,SAAS;wBAAE,gBAAgB;oBAAmB;oBAC9Ca,MAAMC,KAAKC,SAAS,CAAC;wBAAE3D;wBAAQ4D,UAAUpC,OAAOb,GAAG;oBAAC;gBACtD;gBACA,MAAMkD,UAAU,MAAMN,SAASO,IAAI,GAAGR,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;gBACpD,IAAI,CAACC,SAASQ,EAAE,EAAE;;oBAChB,OAAO,KAAK1D,wBAAgBwD,2BAAAA,QAASG,KAAK,mBAAI,8BAA8B;wBAC1EC,SAAS;wBACTC,gBAAgB;oBAClB;gBACF;gBACA7D,gBAAgB,kBAAkB;oBAAE4D,SAAS;oBAAWE,SAAS;gBAAM;YACzE,EAAE,eAAM;gBACN9D,gBAAgB,8BAA8B;oBAC5C4D,SAAS;oBACTC,gBAAgB;gBAClB;YACF;QACF,GACA;QAAC5D;QAASF;QAAMJ;QAAQK;KAAgB;IAG1C;;;;;;;;GAQC,GACD,MAAM,CAAC+D,YAAYC,cAAc,GAAGzF,SAAqB;IACzD;;;;;GAKC,GACD,MAAM0F,aAAa,CAAC7D;QAClB,IAAIT,QAAQ,OAAOA;QACnB,MAAMuE,QAAQC,MAAMC,OAAO,CAAChE,2BAAAA,QAASjB,iBAAiB,IAClDiB,QAAQjB,iBAAiB,CAAC,EAAE,GAC5B;QACJ,OAAO,OAAO+E,UAAU,YAAYA,QAAQA,QAAQ;IACtD;IACA,MAAMG,iBAAiBN,aAAaE,WAAWF,cAAc;IAE7D,MAAMO,iBAAiB,CAAClE,UACtBA,QAAQlB,WAAW,KAAK,WACpB;YACE;gBACEqF,KAAK;gBACLzC,OAAO;gBACP0C,oBAAM,KAAChI;oBAAQiI,MAAMpI,wBAAwBoI,IAAI;oBAAEC,MAAM;;gBACzDC,aAAa;gBACbC,SAAS,IAAM,KAAKnC,mBAAmBrC;YACzC;SACD,GACD;YACE,oEAAoE;YACpE,2DAA2D;eACvDD,SAASC,WACT;gBACE;oBACEmE,KAAK;oBACLzC,OAAO;oBACP0C,oBAAM,KAAChI;wBAAQiI,MAAMrI,qBAAqBqI,IAAI;wBAAEC,MAAM;;oBACtDG,MAAM1E,SAASC;gBACjB;aACD,GACD,EAAE;YACN,+DAA+D;eAC3D6D,WAAW7D,WACX;gBACE;oBACEmE,KAAK;oBACLzC,OAAO;oBACP0C,oBAAM,KAAChI;wBAAQiI,MAAMnI,mBAAmBmI,IAAI;wBAAEC,MAAM;;oBACpDE,SAAS,IAAMZ,cAAc5D;gBAC/B;aACD,GACD,EAAE;SACP;IACP,MAAM0E,iBAA+B;QACnC;YACEC,OAAO;YACPC,YAAY;YACZC,MAAM;YACNC,UAAU;YACV;;;;OAIC,GACDC,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAE;gBAC3B,MAAM8B,OACJ9B,QAAQlB,WAAW,KAAK,WAAWkB,QAAQiF,WAAW,GAAGjF,QAAQ8B,IAAI;gBACvE,qBACE,MAACpE;oBAAMwH,WAAU;oBAAMC,SAAS;oBAAGC,IAAI;wBAAEC,YAAY;wBAAYP,UAAU;oBAAE;;sCAC3E,KAACnH;4BAAW6F,SAAQ;4BAAQ8B,MAAM;sCAC/BtF,QAAQnB,KAAK;;wBAEfiD,qBACC,KAACnE;4BAAW6F,SAAQ;4BAAUb,OAAM;4BAAiB2C,MAAM;sCACxDxD;6BAED;;;YAGV;QACF;QACA;YACE;;;;;;OAMC,GACD6C,OAAO;YACPC,YAAY;YACZW,OAAO;YACPC,aAAa,CAACC,QAAQzF,UAAYA,QAAQd,OAAO;YACjD6F,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAE,GAC3BA,QAAQlB,WAAW,KAAK,yBACtB,KAACzB;oBAAKqE,OAAOzC,iBAAiBe,QAAQd,OAAO;oBAAGyD,OAAM;oBAAU2B,MAAK;mCAErE,KAACjH;oBAAKqE,OAAOzC,iBAAiBe,QAAQd,OAAO;oBAAGoF,MAAK;oBAAQd,SAAQ;;QAE3E;QACA;;;;KAIC,MACGjE,UAAU,OACV;YACE;gBACEoF,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACV,wDAAwD;gBACxDU,aAAa,CAACC,QAAiBzF,UAC7B1B,aAAakB,UAAUQ,QAAQjB,iBAAiB;gBAClDgG,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAgB,GACzC1B,aAAakB,UAAUQ,QAAQjB,iBAAiB;YACpD;SACD,GACD,EAAE;QACN;YACE4F,OAAO;YACPC,YAAY;YACZW,OAAO;YACP,kDAAkD;YAClDC,aAAa,CAACC,QAAQzF;;oBAAYA,mCAAAA,2BAAAA,4BAAAA;gCAAAA,qBAAAA,QAAQ0F,SAAS,sBAAjB1F,6BAAAA,mBAAmB2F,MAAM,sBAAzB3F,4BAAAA,gCAAAA,yCAAAA,oCAAAA,0BAA+B4F,OAAO,qBAAtC5F,uCAAAA,6CAA8C;;YAChF+E,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAE;;oBAC3BA,2BAAAA;gCAAAA,qBAAAA,QAAQ0F,SAAS,sBAAjB1F,4BAAAA,mBAAmB2F,MAAM,qBAAzB3F,+BAAAA,oBAA8B6F,cAAc,qBAAM;;QACtD;QACApJ,kBACE,CAACuD;gBAEiBA;iCADhB,KAACzD;gBACCmF,OAAOzB,QAAOD,iBAAAA,QAAQnB,KAAK,YAAbmB,iBAAiBA,QAAQE,GAAG;gBAC1C4F,OAAO5B,eAAelE;;WAG1B;YAAEuF,OAAO;QAAG;KAEf;IAED,qBACE;;0BACE,MAACpJ;gBACC4J,QAAQxG,UAAU,OAAO,UAAU;gBACnCyG,MAAMnK,eAAe,eAAe;oBAClCoK,QAAQ;gBACV;gBACAC,cAAc;gBACdC,cAAc;gBACdC,iBAAgB;;oBAEf7G,UAAU,qBACT,KAAC5B;wBACC6F,SAAQ;wBACRb,OAAM;wBACNyC,IAAI;4BAAEiB,IAAI5F,MAAMpB,MAAM,GAAG,IAAI;wBAAE;kCAE9B,mEACC;yBAEF;oBACHkB,YAAYlB,MAAM,KAAK,KAAKoB,MAAMpB,MAAM,KAAK,kBAC5C,KAAC1B;wBAAW6F,SAAQ;wBAAQb,OAAM;kCAC/BpD,UAAU,OACP,8BACA,2DACA;uCAGN;;0CACE,KAACjD,8BAAoByF,cAAcuE,UAAU;0CAC7C,KAAC9J;gCACC+J,cAAW;gCACXvE,MAAMD,cAAcC,IAAI;gCACxBwE,SAASzE,cAAc0E,aAAa,CAAC/B;+BAGjC3C,cAAc2E,SAAS;gCAC3BC,aAAY;gCACZ,gEAAgE;gCAChE,oBAAoB;gCACpBC,UAAU,CAAC5G,UAAiB,GAAGA,QAAQlB,WAAW,CAAC,CAAC,EAAEkB,QAAQE,GAAG,EAAE;gCACnE2G,WAAWjK;;4BAEZ8D,kCACC,KAACvD;gCAAM2J,UAAS;gCAAO1B,IAAI;oCAAE2B,IAAI;gCAAE;0CAChCxH,UAAU,OACP,CAAC,WAAW,EAAEhB,gBAAgB,oBAAoB,CAAC,GACnD,sDACA,+BACA,CAAC,WAAW,EAAEA,gBAAgB,wBAAwB,CAAC,GACvD,GAAGA,gBAAgB,kCAAkC,CAAC,GACtD,oDACA;iCAEJ;;;;;0BAYV,MAACjB;gBACC0J,MAAM/F,QAAQ0C;gBACdsD,SAAS,IAAMrD,cAAc;gBAC7BsD,UAAS;gBACTC,SAAS;;kCAET,KAAC1J;2CAAakG,8BAAAA,WAAY9E,KAAK,oBAAI;;kCACnC,KAACrB;kCACEmG,CAAAA,8BAAAA,WAAYzD,GAAG,KAAI+D,+BAClB,KAACnI;4BACCyD,QAAQ0E;4BACRmD,YAAW;4BACXC,UAAUpH,OAAO0D,WAAWzD,GAAG;6BAE/B;;kCAEN,KAAC3C;kCACC,cAAA,KAACH;4BAAOoG,SAAQ;4BAAYgB,SAAS,IAAMZ,cAAc;sCACtD;;;;;;;AAMb;AACAtE,aAAa2F,WAAW,GAAG;AAE3B,eAAe3F,aAAY"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/contacts-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n normalizeContactEmail,\n pluginDocsHelp,\n type ConsolePluginOrgMount,\n} from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { useListRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-rows-filter'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, limit, orderBy, query, where } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { orgSiteNames } from './inbox-org-sites'\n\n/**\n * How many members and how many leads the contacts table reads.\n *\n * A ceiling rather than a page size — see the two queries, which explain why\n * this one table cannot be paged by the server without breaking the dedupe\n * between them.\n */\nconst CONTACT_CEILING = 200\n\n/*\n * What the contacts grid's Filters panel offers (AGL-3317). The card holds\n * both capped windows, so it answers the panel over every contact they read;\n * the notice under the table says when a cap truncated one. Type reads\n * `typeKey`: `member`, or `lead` with the lead's `source` when it has one,\n * which is what the column draws. On the organization's Inbox, Site matches\n * any site that captured the lead.\n */\nconst CONTACT_FILTER_FIELDS = [\n inMemoryListField('email', 'text'),\n inMemoryListField('contactKind', 'select', 'typeKey'),\n]\nconst ORG_CONTACT_FILTER_FIELDS = [\n ...CONTACT_FILTER_FIELDS,\n {\n ...inMemoryListField('capturedByHostIds', 'select'),\n tokensPath: 'capturedByHostIds',\n verbatimTokens: true,\n },\n]\nconst CONTACT_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Email',\n contactKind: 'Type',\n capturedByHostIds: 'Site',\n}\nconst CONTACT_SEARCH_FIELDS = ['email', 'displayName', 'name', 'source'] as const\n\n/** How a contact's type reads, from its `typeKey`. */\nconst contactTypeLabel = (typeKey: string): string =>\n typeKey === 'member'\n ? 'Member'\n : typeKey.startsWith('lead:')\n ? `Lead · ${typeKey.slice('lead:'.length)}`\n : 'Lead'\n\n/**\n * The Members & leads section of the Inbox (AGL-109): everybody a site\n * collected, in one list.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n *\n * On the organization's Inbox (`hostId: null`, AGL-3303) it lists the\n * organization's leads with the sites each was captured by. A member signs up\n * to ONE site and lives under it, so members are listed once a site is\n * picked, which is the page handing this card that site.\n */\nexport function ContactsCard({\n hostId,\n orgMount,\n}: {\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*==========================================\n * SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.\n *\n * Both reads were `limit(200)` with no `orderBy` and a client sort on top —\n * the same document-id sample the submissions list used to take, and both\n * now name the order the rows are rendered in. `createdAt` is safe on both:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction (every other membership path\n * updates an existing document), `recordVisitorLead` is the only writer\n * that creates a lead and stamps it on every `tx.create`, and neither\n * collection is in `IMPORTABLE_FIELDS`.\n *\n * What they must NOT do is page the query, and the reason is the dedupe\n * below: a lead is hidden when a MEMBER already exists on the same address.\n * That test is only correct while both windows are whole. On a ten-row\n * server page it would compare a page of leads against a page of members, so\n * somebody who signed up after leaving their address would render as a\n * Member on one page and again as a Lead on another — one person counted\n * twice, in a list a site owner uses to count people.\n *\n * So the CEILING stays and the page is a slice of the assembled rows. The\n * probe row makes \"there are more contacts than these\" a fact rather than a\n * guess from `length === CONTACT_CEILING`, which is wrong at exactly the\n * count that equals the ceiling.\n *=========================================*/\n const { data: memberDocs } = useFirestoreCollection<any>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n /*\n * The lead silo is the org's (AGL-3275), narrowed to this site by\n * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its\n * pre-migration rows and nothing captured since.\n */\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: hostId == null ? orgMount?.orgId : undefined,\n })\n const siteMembers = (memberDocs ?? []).slice(0, CONTACT_CEILING)\n const { data: leadDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(\n collection(firestore, 'orgs', orgId, 'leads'),\n /*\n * Every site's, on the organization's Inbox: an org-wide member\n * reads the collection unscoped — the rules short-circuit on\n * their reach, as for the CRM's own org list — so a clause here\n * would only narrow what they may already read.\n */\n ...(hostId\n ? [\n where(\n 'visibleTo',\n 'array-contains-any',\n scopeTokensForHost(hostId),\n ),\n ]\n : []),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const leads = (leadDocs ?? []).slice(0, CONTACT_CEILING)\n const contactsTruncated =\n (memberDocs?.length ?? 0) > CONTACT_CEILING ||\n (leadDocs?.length ?? 0) > CONTACT_CEILING\n /*\n * One person renders once, whichever way they came in.\n *\n * Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so\n * somebody could appear as a Member on one row and a Lead on another. The\n * comparison is now the same NORMALIZATION a lead document is keyed by —\n * `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an\n * already-normalized address cannot merge or split anything the normalizer\n * did not, so the two agree by construction.\n *\n * `personKey` itself cannot run here: it needs `node:crypto` and this is a\n * client component. That is why the shared half is the normalizer rather\n * than the digest.\n */\n const dedupedLeads = useMemo(() => {\n const memberKeys = new Set(\n siteMembers\n .map((member: any) => normalizeContactEmail(member.email))\n .filter(Boolean),\n )\n return leads.filter(\n (lead: any) => !memberKeys.has(normalizeContactEmail(lead.email)),\n )\n }, [leads, siteMembers])\n /*\n * ONE list of two collections — members first, then the leads that are not\n * already members — so the grid pages the concatenation rather than either\n * read, and a page stays whole across the seam between them. Both reads are\n * ceilinged and complete below the ceiling, so the grid's count is the whole\n * deduped list.\n */\n const contacts = useMemo(\n () => [\n ...siteMembers.map((member: any) => ({\n ...member,\n contactKind: 'member',\n typeKey: 'member',\n })),\n ...dedupedLeads.map((lead: any) => ({\n ...lead,\n contactKind: 'lead',\n typeKey: lead.source ? `lead:${lead.source}` : 'lead',\n })),\n ],\n [siteMembers, dedupedLeads],\n )\n /*\n * A lead's source is open-ended, so the Type choices are the ones the rows\n * hold, member and plain lead first.\n */\n const contactFilterOptions = useMemo(() => {\n const leadSources = [\n ...new Set(\n contacts\n .map((contact: any) => contact.typeKey as string)\n .filter((typeKey) => typeKey.startsWith('lead:')),\n ),\n ].sort()\n return {\n contactKind: ['member', 'lead', ...leadSources].map((value) => ({\n value,\n label: contactTypeLabel(value),\n })),\n ...(hostId == null\n ? {\n capturedByHostIds: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {}),\n }\n }, [contacts, hostId, orgMount])\n const contactFilter = useListRowsFilter({\n rows: contacts,\n fields: hostId == null ? ORG_CONTACT_FILTER_FIELDS : CONTACT_FILTER_FIELDS,\n options: contactFilterOptions,\n headers: CONTACT_FILTER_HEADERS,\n search: CONTACT_SEARCH_FIELDS,\n })\n /*\n * REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a\n * client delete. A member's password hash lives in a document no client can\n * reach, so deleting the profile from here would leave it behind: the route\n * deletes both, and the rules refuse a client delete of any member that\n * has one.\n */\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n // Members are listed only under a site, so there is one to delete from.\n if (!hostId) return\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const response = await authorizedFetch(user, '/api/membership/admin-remove', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, memberId: member.$id }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n } catch {\n enqueueSnackbar('The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n },\n [confirm, user, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n /*\n * The site a lead's attribution is asked of. Under a site it is that site;\n * across every site a lead is one org row that several sites may have met,\n * and the credit is filed by the site whose capture made it — the first in\n * `capturedByHostIds`, the site the CRM's own lead page asks as well.\n */\n const originSite = (contact: any): string | null => {\n if (hostId) return hostId\n const first = Array.isArray(contact?.capturedByHostIds)\n ? contact.capturedByHostIds[0]\n : null\n return typeof first === 'string' && first ? first : null\n }\n const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null\n\n const contactActions = (contact: any): RowActionsMenuItem[] =>\n contact.contactKind === 'member'\n ? [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(contact)(),\n },\n ]\n : [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(contact)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(contact) as string,\n },\n ]\n : []),\n // A lead no site captured has no site to ask for its campaign.\n ...(originSite(contact)\n ? [\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(contact),\n },\n ]\n : []),\n ]\n const contactColumns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name =\n contact.contactKind === 'member' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n /*\n WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both\n lead writers — `'signup'` and `'booking'` — since AGL-109, so a site\n owner can tell a membership sign-up from a booking. Falls back to the\n bare label for a row written before the field, or by a future writer\n that omits it.\n */\n field: 'contactKind',\n headerName: 'Type',\n width: 170,\n valueGetter: (_value, contact) => contact.typeKey,\n renderCell: ({ row: contact }) =>\n contact.contactKind === 'member' ? (\n <Chip label={contactTypeLabel(contact.typeKey)} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={contactTypeLabel(contact.typeKey)} size=\"small\" variant=\"outlined\" />\n ),\n },\n /*\n KNOWN BY, on the organization's Inbox: every site that captured the\n person, as the CRM's organization Leads list names them. A lead is one\n org row, so \"which site\" is a set, never one answer.\n */\n ...(hostId == null\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Site',\n flex: 1,\n minWidth: 160,\n // Sorted and drawn by name; the filter matches the ids.\n valueGetter: (_value: unknown, contact: any) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n renderCell: ({ row: contact }: { row: any }) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'createdAt',\n headerName: 'Joined',\n width: 200,\n // Sorted on the instant, drawn as a local string.\n valueGetter: (_value, contact) => contact.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={contactActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <>\n <CardDisplay\n header={hostId == null ? 'Leads' : 'Site Members & Leads'}\n help={pluginDocsHelp('forms', {\n anchor: '#filter-the-inbox',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: leads.length ? 2 : 1 }}\n >\n {'Every site’s leads, newest first. Members sign up to one site ' +\n 'each — choose a site to list its members beside its leads.'}\n </Typography>\n ) : null}\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {hostId == null\n ? 'No leads on any site yet.'\n : 'No members yet — visitors can join at /signup on your ' +\n 'site; sign-ups also appear here as leads.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips {...contactFilter.chipsProps} />\n <ListTable\n aria-label=\"Site members and leads\"\n rows={contactFilter.rows}\n columns={contactFilter.filterColumns(contactColumns)}\n // The panel and the search are the grid's; the card answers\n // them over every contact both windows read.\n {...contactFilter.gridProps}\n noRowsLabel=\"No contacts match these filters\"\n // A member and a lead are two collections, so an id alone could\n // name one of each.\n getRowId={(contact: any) => `${contact.contactKind}:${contact.$id}`}\n rowHeight={TABLE_ROW_HEIGHT}\n />\n {contactsTruncated ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {hostId == null\n ? `Paging the ${CONTACT_CEILING} newest leads. Your ` +\n 'sites hold more than that — the CRM’s Leads list ' +\n 'reaches every one of them.'\n : `Paging the ${CONTACT_CEILING} newest members and the ` +\n `${CONTACT_CEILING} newest leads. This site has more ` +\n 'than that — the campaign audiences still reach ' +\n 'everyone, whether or not they are listed here.'}\n </Alert>\n ) : null}\n </>\n )}\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id && leadOriginSite ? (\n <InboxRecordAttributionZone\n hostId={leadOriginSite}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["normalizeContactEmail","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListRowActions","ListTable","listActionsColumn","inMemoryListField","useListRowsFilter","TABLE_ROW_HEIGHT","useSnackbar","authorizedFetch","useFirestore","useFirestoreCollection","useOrgDataScope","useUser","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","limit","orderBy","query","where","useCallback","useMemo","useState","useRecordRouteContext","scopeTokensForHost","orgSiteNames","CONTACT_CEILING","CONTACT_FILTER_FIELDS","ORG_CONTACT_FILTER_FIELDS","tokensPath","verbatimTokens","CONTACT_FILTER_HEADERS","email","contactKind","capturedByHostIds","CONTACT_SEARCH_FIELDS","contactTypeLabel","typeKey","startsWith","slice","length","ContactsCard","hostId","orgMount","firestore","data","user","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","memberDocs","idField","orgId","undefined","siteMembers","leadDocs","leads","contactsTruncated","dedupedLeads","memberKeys","Set","map","member","filter","Boolean","lead","has","contacts","source","contactFilterOptions","leadSources","sort","value","label","hosts","host","id","name","contactFilter","rows","fields","options","headers","search","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","response","method","body","JSON","stringify","memberId","payload","json","ok","error","variant","allowDuplicate","persist","leadOrigin","setLeadOrigin","originSite","first","Array","isArray","leadOriginSite","contactActions","key","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","mb","chipsProps","aria-label","columns","filterColumns","gridProps","noRowsLabel","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -90,7 +90,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
90
90
  *
91
91
  * Both are keyed with `emailSuppressionKey`, one derivation, so the two reads
92
92
  * cannot disagree about which document to look for.
93
- */ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, assignmentReadout, isOrgWideMember, readMarketingBasis, registerPluginApiRoute, resolveBrandingProfile } from "@aglyn/aglyn/server";
93
+ */ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, assignmentReadout, grantEntriesAsRecorded, isOrgWideMember, readMarketingBasis, registerPluginApiRoute, resolveBrandingProfile } from "@aglyn/aglyn/server";
94
94
  import { emailSuppressionKey, enrollListMember, firebaseAdmin, getOrgForHost, hostSendingIdentity, isEmailSuppressed, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
95
95
  // The leaf, not the barrel: this plugin's specs substitute the barrel
96
96
  // wholesale, and the lookup must reach the real index logic under them.
@@ -443,11 +443,20 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
443
443
  * from the other side: a refusal recorded on a record that was later
444
444
  * merged into another is the survivor's refusal, and the sender's address
445
445
  * may be the one that became an alternate.
446
+ *
447
+ * The person's grants come back beside the verdict, off the same document
448
+ * (AGL-3320): a pass-through carries them onto the membership as the record
449
+ * holds them, rather than re-recording the opt-in over the group as it
450
+ * stands today.
446
451
  */ async function storedConsentForAddress(hostId, group, email) {
447
452
  try {
448
453
  const contacts = await orgDataCollectionForHost(hostId, 'contacts');
449
454
  const found = await findContactByEmail(contacts, email);
450
- return readMarketingBasis(found ? found.data() : null, group);
455
+ const data = found ? found.data() : null;
456
+ return {
457
+ stored: readMarketingBasis(data, group),
458
+ grants: grantEntriesAsRecorded(data, group)
459
+ };
451
460
  } catch (error) {
452
461
  console.error('[inbox] consent lookup failed', error);
453
462
  /*
@@ -457,17 +466,20 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
457
466
  * would leave the attestation control on screen and let the merchant add
458
467
  * somebody whose stored refusal this route simply failed to see, and no
459
468
  * later surface would ever revisit it. A refusal costs a retry.
460
- */ return _extends({}, readMarketingBasis(null, group), {
461
- basis: 'declined',
462
- // Attributed to nobody, because nobody asserted this: it is what a
463
- // failed read falls back to, not a refusal anyone recorded. Claiming
464
- // `'person'` here would put a refusal in the audit trail that the
465
- // person never made.
466
- assertedBy: null,
467
- source: null,
468
- basisAtMs: null,
469
- capturedAtMs: null
470
- });
469
+ */ return {
470
+ stored: _extends({}, readMarketingBasis(null, group), {
471
+ basis: 'declined',
472
+ // Attributed to nobody, because nobody asserted this: it is what a
473
+ // failed read falls back to, not a refusal anyone recorded. Claiming
474
+ // `'person'` here would put a refusal in the audit trail that the
475
+ // person never made.
476
+ assertedBy: null,
477
+ source: null,
478
+ basisAtMs: null,
479
+ capturedAtMs: null
480
+ }),
481
+ grants: {}
482
+ };
471
483
  }
472
484
  }
473
485
  /**
@@ -502,7 +514,7 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
502
514
  // lists read across it, as the send will read them.
503
515
  const group = await consentGroupForSite(hostId);
504
516
  const suppression = await addressSuppression(hostId, context.email, undefined, group);
505
- const stored = await storedConsentForAddress(hostId, group, context.email);
517
+ const { stored } = await storedConsentForAddress(hostId, group, context.email);
506
518
  const readout = assignmentReadout({
507
519
  stored,
508
520
  suppression
@@ -596,7 +608,7 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
596
608
  error: 'Unknown list'
597
609
  });
598
610
  }
599
- const stored = await storedConsentForAddress(hostId, group, context.email);
611
+ const { stored, grants } = await storedConsentForAddress(hostId, group, context.email);
600
612
  const nowMs = Date.now();
601
613
  const decision = assignmentBasis({
602
614
  stored,
@@ -610,7 +622,7 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
610
622
  // lets the UI offer the attestation control on exactly one of them.
611
623
  return refuse(decision.refusal === 'declined' ? 409 : 422, decision.refusal);
612
624
  }
613
- const enrollment = await enrollListMember({
625
+ const enrollment = await enrollListMember(_extends({
614
626
  listRef,
615
627
  group,
616
628
  email: context.email,
@@ -620,7 +632,9 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
620
632
  // hand is not a rule match that lapsed.
621
633
  via: 'manual',
622
634
  consent: decision
623
- });
635
+ }, decision.basis === 'contact-opt-in' ? {
636
+ grantEntries: grants
637
+ } : {}));
624
638
  if (enrollment.enrolled === false) {
625
639
  /*
626
640
  * The membership itself records a refusal that the CRM record did not.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/inbox/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The Inbox's two acts on the person who sent a submission: answer them, and\n * put them on a marketing list.\n *\n * `POST inbox/reply` sends one message. `POST inbox/assign-list` enrolls, and\n * `POST inbox/list-options` tells the merchant what they may do before they\n * do it.\n *\n * ## The two acts are kept apart everywhere\n *\n * A reply is TRANSACTIONAL — the person asked to be contacted by submitting\n * the form, and answering them is the transaction they started. A list is\n * MARKETING — a standing invitation to mail them about things they never\n * asked about. So they are separate routes, separate records and separate\n * cards on screen, and neither is a side effect of the other: replying\n * enrolls nobody and writes no consent, and enrolling sends nothing and\n * meters nothing. Folding either into the other would make a merchant's\n * ordinary act of answering a customer into an act with consequences they\n * did not choose. The enrollment rule itself is the framework's\n * `list-assignment-policy`, shared with the Emails console's audience card so\n * that both surfaces refuse the same people for the same stated reasons.\n *\n * ## The boundary this feature sits on, stated because the UI must say it too\n *\n * The Inbox is not a mailbox. Nothing in the INBOX receives mail: no route\n * of its own, no MX record pointed at a submission, no parser. A submission\n * arrived as an HTTP POST, not as a message, so there is no `Message-ID` to\n * thread against and a reply is always the FIRST message in its conversation.\n * (The platform's one receiving door is the CRM's capture address —\n * `POST /api/crm/inbound` in the console app, AGL-2657 — which files a\n * forwarded or copied message on a contact's timeline. It threads nothing\n * here: a submission is not mail, and the Inbox's statement stands.)\n *\n * That decides two things:\n *\n * - **No `In-Reply-To` or `References` header is sent.** There is nothing to\n * put in them. Inventing an identifier would produce headers that reference\n * a message no mail server has ever seen, which threads nothing and makes\n * some filters treat the message as forged.\n * - **`Reply-To` is the sender's own console account address**, so when the\n * recipient answers, the answer reaches a human in a real mailbox. It does\n * not come back here, and the composer says so. A reply that went to the\n * platform's unmonitored address would be a message the merchant never sees\n * and the customer believes was received.\n *\n * The thread the merchant sees is ours, stored under the submission, and it\n * holds what we sent — never what came back, because nothing comes back.\n *\n * ## Sending identity\n *\n * The `From:` address is whatever THIS SITE sends as — `hostSendingIdentity`,\n * the same resolution a campaign gets — so a site with a domain of its own\n * replies on it, and a site without one replies on the shared pool, which is\n * correct for a reply because a reply is transactional.\n *\n * The display name varies separately, through `resolveBrandingProfile`, which\n * is entitlement-gated: a merchant without white-label replies under the\n * platform's name even on their own domain.\n *\n * `Reply-To` stays load-bearing either way, and not as a stopgap. Nothing\n * here receives mail, so the answer has to be routed to a mailbox somebody\n * actually reads — and `Reply-To` is the one header that may name an address\n * on a domain this platform has never verified.\n *\n * ## Consent and suppression\n *\n * A reply is transactional: the recipient asked to be contacted by submitting\n * the form, so no marketing-consent record is required and none is read. The\n * suppression lists still apply — both of them — and this is the first send\n * path in the product to consult BOTH on one address:\n *\n * - the platform list, through `isEmailSuppressed`, which fails closed;\n * - this site's list, under `hosts/{hostId}/suppressions`.\n *\n * Both are keyed with `emailSuppressionKey`, one derivation, so the two reads\n * cannot disagree about which document to look for.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n assignmentReadout,\n isOrgWideMember,\n readMarketingBasis,\n registerPluginApiRoute,\n resolveBrandingProfile,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailSuppressionKey,\n enrollListMember,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, not the barrel: this plugin's specs substitute the barrel\n// wholesale, and the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\n// The leaf for the same reason: which sites a group's opt-outs live on is the\n// rule, and a spec's barrel double must not be able to stand in for it.\nimport { consentGroupOptOutHosts } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n REPLY_BODY_MAX,\n REPLY_SUBJECT_MAX,\n composeReplyBody,\n replyRecipient,\n type ReplyRefusal,\n} from './model/reply-policy'\n\n/** Where a reply is stored, under the submission it answers. */\nexport const REPLIES_SUBCOLLECTION = 'replies'\n\n/** `context` on the send: the log label and the Resend attribution tag. */\nexport const REPLY_CONTEXT = 'inbox-reply'\n\n/** What a refusal says to the merchant. One line, naming the cause. */\nconst REFUSAL_MESSAGES: Record<ReplyRefusal, string> = {\n 'no-address': 'This submission has no email field, so there is nobody to reply to.',\n 'unroutable-address':\n 'The email address on this submission is not a valid address.',\n 'suppressed-platform':\n 'This address bounced or reported a message as spam, so it cannot be mailed.',\n 'suppressed-host':\n 'This address unsubscribed from this site, so it cannot be mailed.',\n}\n\n/**\n * Is this address suppressed, on either list?\n *\n * Order matters only for the reason reported, not the outcome. The platform\n * list is read first because it is the one that carries a bounce learned\n * anywhere — including on a send that named no site — and that is the more\n * useful thing to tell a merchant who is about to retype the message.\n *\n * Named for the ADDRESS rather than for either act, because both use it: a\n * reply must not go to a dead or complaining mailbox, and a list enrollment\n * must not put one on a standing audience. A second copy for the second act\n * would be two answers to \"may this address be mailed\".\n *\n * \"This site\" is its consent group: the site list is read on every site of\n * `group`, because somebody who unsubscribed from one site of a declared\n * group unsubscribed from the sender this site is. A group of one is the one\n * read it always was.\n */\nexport async function addressSuppression(\n hostId: string,\n email: string,\n firestore?: unknown,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<ReplyRefusal | null> {\n const hostIds =\n group && group.hostId === hostId ? consentGroupOptOutHosts(group) : [hostId]\n const key = emailSuppressionKey(email)\n // `emailSuppressionKey` returns null for an address it cannot key, and\n // `isEmailSuppressed` already answers `true` for that case. Treating it as\n // suppressed here keeps the two halves agreeing: an address we cannot key\n // is an address we cannot prove is safe to mail.\n if (!key) return 'suppressed-platform'\n if (await isEmailSuppressed(email, firestore)) return 'suppressed-platform'\n const db = (firestore ?? firebaseAdmin.app().firestore()) as any\n const docs = await Promise.all(\n hostIds.map((id) =>\n db.collection('hosts').doc(id).collection('suppressions').doc(key).get(),\n ),\n )\n return docs.some((doc) => doc.exists) ? 'suppressed-host' : null\n}\n\n/**\n * Reply to one submission.\n *\n * Body: `{ hostId, submissionId, subject, message }`. The recipient is\n * deliberately absent — it is read off the stored submission, so this route\n * cannot be pointed at an address of the caller's choosing.\n */\nexport const inboxReplyHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n const subject = String(req.body?.subject ?? '')\n .trim()\n .slice(0, REPLY_SUBJECT_MAX)\n const message = String(req.body?.message ?? '')\n .trim()\n .slice(0, REPLY_BODY_MAX)\n if (!hostId || !submissionId) {\n return res.status(400).json({ error: 'Missing hostId or submissionId' })\n }\n if (!subject || !message) {\n return res.status(400).json({ error: 'Missing subject or message' })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n // The address the reply goes back to, and the only thing that makes this\n // send worth making. A console account with no address on it would put\n // the customer's answer nowhere, so it refuses rather than sending a\n // message that invites a reply into a void.\n const replyTo = String(decoded.email ?? '')\n if (!replyTo) {\n return res.status(400).json({\n error:\n 'Your account has no email address, so a reply would have nowhere to come back to.',\n })\n }\n\n const submissionRef = hostRef\n .collection('formSubmissions')\n .doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return res.status(404).json({ error: 'Unknown submission' })\n }\n\n const fields = (submission.get('fields') ?? {}) as Record<string, unknown>\n const recipient = replyRecipient(fields)\n if ('refusal' in recipient) {\n return res\n .status(422)\n .json({ error: REFUSAL_MESSAGES[recipient.refusal], reason: recipient.refusal })\n }\n\n // The org once, for the sender's consent group here and its brand below.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const suppressed = await addressSuppression(\n hostId,\n recipient.email,\n undefined,\n await consentGroupForSite(\n hostId,\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n ),\n )\n if (suppressed) {\n return res\n .status(409)\n .json({ error: REFUSAL_MESSAGES[suppressed], reason: suppressed })\n }\n\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? '',\n )\n const branding = resolveBrandingProfile(owner?.org as never)\n\n // No `html` is passed on purpose. `sendEmail` synthesizes the HTML part\n // from `text`, which is the single place that guarantee is enforced; a\n // hand-built one here would be a second place it can be forgotten.\n // No `priority` either: absent, it resolves to transactional, which the\n // platform governor may never refuse. That is the correct class — a reply\n // is a person answering a person, and it cannot be retried by a sweep.\n const result = await sendEmail({\n to: recipient.email,\n subject,\n text: composeReplyBody({ message, fields, siteName }),\n replyTo,\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: REPLY_CONTEXT,\n })\n\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reply could not be sent.',\n reason: (result as { reason?: string }).reason ?? 'unknown',\n })\n }\n\n // Metered as transactional, so it enters the cost meter and never the\n // campaign meter a plan limit can refuse.\n void meterHostEmail(hostId, 1, 'transactional')\n\n const sentAtMs = Date.now()\n const replyRef = await submissionRef\n .collection(REPLIES_SUBCOLLECTION)\n .add({\n to: recipient.email,\n subject,\n // The merchant's own words, not the composed wire body. The quote and\n // the attribution line are rendered from the submission every time, so\n // storing them would be storing a copy of a document one field away.\n message,\n replyTo,\n fromName: branding.fromName,\n sentByUid: decoded.uid,\n providerMessageId: (result as { id?: string | null }).id ?? null,\n sentAtMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n // A replied submission is a handled one, so it stops being unread in the\n // same write that records the reply. `repliedAtMs` is what the row reads;\n // the count of replies is a subcollection read the list must not make.\n await submissionRef.set(\n { read: true, repliedAtMs: sentAtMs },\n { merge: true },\n )\n\n return res.status(200).json({\n sent: true,\n replyId: replyRef.id,\n to: recipient.email,\n replyTo,\n sentAtMs,\n })\n } catch (error) {\n console.error('[inbox] reply failed', error)\n return res.status(500).json({ error: 'The reply could not be sent.' })\n }\n}\n\n/** Where an assignment is recorded, under the submission that occasioned it. */\nexport const LIST_ASSIGNMENTS_SUBCOLLECTION = 'listAssignments'\n\n/** The `source` stamped on a member enrolled from the Inbox. */\nexport const ASSIGNMENT_SOURCE = 'inbox'\n\n/**\n * How many lists the picker offers.\n *\n * A ceiling on the READ, so a merchant with an unusual number of lists costs\n * one bounded query rather than a scan, and the response says when it is a\n * floor rather than presenting a slice as the whole set.\n */\nexport const LIST_OPTIONS_LIMIT = 100\n\n/** Everything both list-assignment handlers need, or the refusal to send. */\ntype AssignmentContext =\n | {\n ok: true\n uid: string\n orgId: string\n firestore: FirebaseFirestore.Firestore\n submissionRef: FirebaseFirestore.DocumentReference\n email: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, about which submission, and for which person.\n *\n * ## Two gates, not one\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the security rules\n * put both behind `isOrgWideMember()` — an editor invited to ONE site is an\n * org member with `allHosts: false`, and gating an org-wide write on the host\n * role alone would let a single-site collaborator enroll people into an\n * audience every other site in the org can mail. The Admin SDK evaluates no\n * rules, so this route is the enforcement rather than an echo of it.\n *\n * ## The address is read off the submission\n *\n * Through `replyRecipient`, the same resolver the reply and the Inbox row use.\n * A `to` in the request body would let a site editor enroll an address of\n * their choosing into a marketing audience, which is the same defect the\n * reply handler refuses for the same reason.\n */\nasync function resolveAssignmentContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<AssignmentContext> {\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n if (!hostId || !submissionId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or submissionId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String((await getOrgForHost(hostId).catch(() => null))?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so adding ' +\n 'someone to one needs organization-wide access rather than access ' +\n 'to this site.',\n },\n }\n }\n\n const submissionRef = hostRef.collection('formSubmissions').doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown submission' } }\n }\n const recipient = replyRecipient(\n (submission.get('fields') ?? {}) as Record<string, unknown>,\n )\n if ('refusal' in recipient) {\n return {\n ok: false,\n status: 422,\n body: {\n error: ASSIGNMENT_REFUSAL_MESSAGES[recipient.refusal],\n reason: recipient.refusal,\n },\n }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n orgId,\n firestore,\n submissionRef,\n email: recipient.email,\n }\n}\n\n/**\n * The person's own consent facts, read off the org contact for this address.\n *\n * The CRM record is where a refusal lives: `marketingConsent` is written\n * `false` by exactly one path in the product and it writes a contact. So this\n * is the read that makes `declined` mean something at enrollment time, and an\n * absent contact is honestly `unrecorded` rather than a reason to guess.\n *\n * Read UNSCOPED, deliberately. `scopedToHost` narrows an org collection to\n * what one site may see, and a refusal filtered out by that narrowing is a\n * refusal this route would then step over — the failure mode is mailing\n * somebody who said no. It is safe here because the caller has already been\n * proved an org-wide member, which is the tier the rules grant the whole\n * org's contacts to.\n *\n * Through the org's address index (AGL-2633), for the same failure mode\n * from the other side: a refusal recorded on a record that was later\n * merged into another is the survivor's refusal, and the sender's address\n * may be the one that became an alternate.\n */\nasync function storedConsentForAddress(\n hostId: string,\n group: ConsentGroup,\n email: string,\n): Promise<MarketingConsentRecord> {\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const found = await findContactByEmail(contacts, email)\n return readMarketingBasis(\n found ? (found.data() as Record<string, unknown>) : null,\n group,\n )\n } catch (error) {\n console.error('[inbox] consent lookup failed', error)\n /*\n * FAILS TO `declined`, which reads oddly until you name the alternative.\n * A throwing read cannot say the person consented and cannot say they\n * refused; the question is which way the unknown should fall. `unrecorded`\n * would leave the attestation control on screen and let the merchant add\n * somebody whose stored refusal this route simply failed to see, and no\n * later surface would ever revisit it. A refusal costs a retry.\n */\n return {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody, because nobody asserted this: it is what a\n // failed read falls back to, not a refusal anyone recorded. Claiming\n // `'person'` here would put a refusal in the audit trail that the\n // person never made.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n }\n}\n\n/**\n * `POST inbox/list-options` — what the merchant may do with this sender.\n *\n * Reads only. It exists because the answer needs three things the browser\n * cannot have: the org's lists (rules put them behind org-wide membership,\n * which the acting console session may not hold), the person's consent record\n * (an org contact, same gate), and both suppression lists. Computing any of\n * it client-side would be a second copy of the rule, on the surface whose\n * whole job is to tell the merchant the truth about what is about to happen.\n *\n * Reached by an explicit expansion in the reader, never on mount: it is three\n * reads and a bounded query, and paying them once per opened submission would\n * charge every merchant who never touches lists.\n */\nexport const inboxListOptionsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppression = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n const stored = await storedConsentForAddress(hostId, group, context.email)\n const readout = assignmentReadout({ stored, suppression })\n\n const listsRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n /*\n * Ordered by document id, which is the one key every list has.\n * `limit()` alone answers in that order anyway but does not SAY so, and a\n * page of results whose order is an implementation detail is the shape\n * that turned an audience into a random sample. Ordering by `name` would\n * be worse than either: `orderBy` drops documents missing the field, so a\n * list created without a name would vanish from its own picker.\n */\n const snapshot = await listsRef\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(LIST_OPTIONS_LIMIT + 1)\n .get()\n const lists = snapshot.docs\n .slice(0, LIST_OPTIONS_LIMIT)\n .map((doc: FirebaseFirestore.QueryDocumentSnapshot) => ({\n id: doc.id,\n name: String(doc.get('name') ?? doc.id),\n }))\n\n return res.status(200).json({\n to: context.email,\n lists,\n listsTruncated: snapshot.docs.length > LIST_OPTIONS_LIMIT,\n basis: stored.basis,\n basisAtMs: stored.basisAtMs,\n ...readout,\n })\n } catch (error) {\n console.error('[inbox] list options failed', error)\n return res.status(500).json({ error: 'The lists could not be read.' })\n }\n}\n\n/**\n * `POST inbox/assign-list` — put this sender on a marketing list.\n *\n * Body: `{ hostId, submissionId, listId, attestConsent? }`. `attestConsent` is\n * the merchant STATING that they have this person's permission; it is not a\n * way to name a basis, because the pass-through basis is derived server-side\n * from the person's own record.\n *\n * ## What this act is, and what it is not\n *\n * It is marketing enrollment: the only consumer of a list is a campaign. It\n * is therefore kept entirely separate from the reply — different route,\n * different card in the UI, different record — and replying enrolls nobody.\n * The reverse holds too: enrolling somebody sends them nothing, meters\n * nothing, and is not a promise that they are still mailable when a campaign\n * eventually runs. Suppression is consulted again at send time, in\n * `filterSendableForHost`, because an address can be suppressed the day after\n * it is enrolled and an enrollment-time check that licensed every later send\n * would be a check that passes once and pays out forever.\n */\nexport const inboxAssignListHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listId = String(req.body?.listId ?? '')\n if (!listId) return res.status(400).json({ error: 'Missing listId' })\n const attested = req.body?.attestConsent === true\n\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n const refuse = (status: number, reason: AssignmentRefusal) =>\n res\n .status(status)\n .json({ error: ASSIGNMENT_REFUSAL_MESSAGES[reason], reason })\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppressed = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n if (suppressed) return refuse(409, suppressed)\n\n const listRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown list' })\n }\n\n const stored = await storedConsentForAddress(hostId, group, context.email)\n const nowMs = Date.now()\n const decision = assignmentBasis({\n stored,\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n // 409 for a refusal that no answer can change, 422 for the one the\n // merchant can answer by asserting a basis. The distinction is what\n // lets the UI offer the attestation control on exactly one of them.\n return refuse(decision.refusal === 'declined' ? 409 : 422, decision.refusal)\n }\n\n const enrollment = await enrollListMember({\n listRef,\n group,\n email: context.email,\n source: ASSIGNMENT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own rows\n // away when a person stops matching, and a decision somebody made by\n // hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal that the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds the\n * row, so it is the backstop for every enrollment route; reaching it\n * here means the two records disagree, and the refusal wins.\n */\n return refuse(409, enrollment.refusal === 'declined' ? 'declined' : 'no-address')\n }\n\n /*\n * The attestation trail, beside the reply record and for the same reason:\n * a support question about a marketing list starts with \"who put this\n * person on it, and on what basis\". The member document carries the basis\n * so the send-time join can read it; this carries the same facts where\n * the act happened, which is the only place that survives the person\n * later being removed from the list.\n */\n await context.submissionRef\n .collection(LIST_ASSIGNMENTS_SUBCOLLECTION)\n .add({\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n basisAtMs: decision.atMs,\n assertedByUid: decision.byUid,\n addedByUid: context.uid,\n addedAtMs: nowMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n enrolled: true,\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n })\n } catch (error) {\n console.error('[inbox] list assignment failed', error)\n return res.status(500).json({ error: 'The list assignment failed.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`. Each is reached by a person pressing a button in\n * a browser, so the visitor limiter's per-(site, IP) budget is far above any\n * real use of them and is the right ceiling for surfaces that put mail on the\n * wire or a person into a marketing audience.\n */\nexport function registerInboxConsoleApi(): void {\n registerPluginApiRoute('inbox/reply', inboxReplyHandler)\n registerPluginApiRoute('inbox/list-options', inboxListOptionsHandler)\n registerPluginApiRoute('inbox/assign-list', inboxAssignListHandler)\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","assignmentReadout","isOrgWideMember","readMarketingBasis","registerPluginApiRoute","resolveBrandingProfile","emailSuppressionKey","enrollListMember","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","resolveOrgMembership","findContactByEmail","consentGroupOptOutHosts","sendEmail","FieldValue","REPLY_BODY_MAX","REPLY_SUBJECT_MAX","composeReplyBody","replyRecipient","REPLIES_SUBCOLLECTION","REPLY_CONTEXT","REFUSAL_MESSAGES","addressSuppression","hostId","email","firestore","group","hostIds","key","db","app","docs","Promise","all","map","id","collection","doc","get","some","exists","inboxReplyHandler","req","res","method","status","json","error","String","body","submissionId","subject","trim","slice","message","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","decoded","submission","auth","verifyIdToken","hostRef","memberRole","uid","replyTo","submissionRef","fields","recipient","refusal","reason","owner","catch","suppressed","org","siteName","branding","result","to","text","fromName","sendingIdentity","audience","context","sent","sentAtMs","Date","now","replyRef","add","sentByUid","providerMessageId","createdAt","serverTimestamp","set","read","repliedAtMs","merge","replyId","console","LIST_ASSIGNMENTS_SUBCOLLECTION","ASSIGNMENT_SOURCE","LIST_OPTIONS_LIMIT","resolveAssignmentContext","ok","orgId","membership","member","orgWideWriter","role","orgSuspended","storedConsentForAddress","contacts","found","data","basis","assertedBy","source","basisAtMs","capturedAtMs","inboxListOptionsHandler","suppression","stored","readout","listsRef","snapshot","orderBy","FieldPath","documentId","limit","lists","name","listsTruncated","inboxAssignListHandler","listId","attested","attestConsent","listSnapshot","refuse","listRef","nowMs","decision","actingUid","enrollment","via","consent","enrolled","listName","memberId","atMs","assertedByUid","byUid","addedByUid","addedAtMs","registerInboxConsoleApi"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/inbox/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The Inbox's two acts on the person who sent a submission: answer them, and\n * put them on a marketing list.\n *\n * `POST inbox/reply` sends one message. `POST inbox/assign-list` enrolls, and\n * `POST inbox/list-options` tells the merchant what they may do before they\n * do it.\n *\n * ## The two acts are kept apart everywhere\n *\n * A reply is TRANSACTIONAL — the person asked to be contacted by submitting\n * the form, and answering them is the transaction they started. A list is\n * MARKETING — a standing invitation to mail them about things they never\n * asked about. So they are separate routes, separate records and separate\n * cards on screen, and neither is a side effect of the other: replying\n * enrolls nobody and writes no consent, and enrolling sends nothing and\n * meters nothing. Folding either into the other would make a merchant's\n * ordinary act of answering a customer into an act with consequences they\n * did not choose. The enrollment rule itself is the framework's\n * `list-assignment-policy`, shared with the Emails console's audience card so\n * that both surfaces refuse the same people for the same stated reasons.\n *\n * ## The boundary this feature sits on, stated because the UI must say it too\n *\n * The Inbox is not a mailbox. Nothing in the INBOX receives mail: no route\n * of its own, no MX record pointed at a submission, no parser. A submission\n * arrived as an HTTP POST, not as a message, so there is no `Message-ID` to\n * thread against and a reply is always the FIRST message in its conversation.\n * (The platform's one receiving door is the CRM's capture address —\n * `POST /api/crm/inbound` in the console app, AGL-2657 — which files a\n * forwarded or copied message on a contact's timeline. It threads nothing\n * here: a submission is not mail, and the Inbox's statement stands.)\n *\n * That decides two things:\n *\n * - **No `In-Reply-To` or `References` header is sent.** There is nothing to\n * put in them. Inventing an identifier would produce headers that reference\n * a message no mail server has ever seen, which threads nothing and makes\n * some filters treat the message as forged.\n * - **`Reply-To` is the sender's own console account address**, so when the\n * recipient answers, the answer reaches a human in a real mailbox. It does\n * not come back here, and the composer says so. A reply that went to the\n * platform's unmonitored address would be a message the merchant never sees\n * and the customer believes was received.\n *\n * The thread the merchant sees is ours, stored under the submission, and it\n * holds what we sent — never what came back, because nothing comes back.\n *\n * ## Sending identity\n *\n * The `From:` address is whatever THIS SITE sends as — `hostSendingIdentity`,\n * the same resolution a campaign gets — so a site with a domain of its own\n * replies on it, and a site without one replies on the shared pool, which is\n * correct for a reply because a reply is transactional.\n *\n * The display name varies separately, through `resolveBrandingProfile`, which\n * is entitlement-gated: a merchant without white-label replies under the\n * platform's name even on their own domain.\n *\n * `Reply-To` stays load-bearing either way, and not as a stopgap. Nothing\n * here receives mail, so the answer has to be routed to a mailbox somebody\n * actually reads — and `Reply-To` is the one header that may name an address\n * on a domain this platform has never verified.\n *\n * ## Consent and suppression\n *\n * A reply is transactional: the recipient asked to be contacted by submitting\n * the form, so no marketing-consent record is required and none is read. The\n * suppression lists still apply — both of them — and this is the first send\n * path in the product to consult BOTH on one address:\n *\n * - the platform list, through `isEmailSuppressed`, which fails closed;\n * - this site's list, under `hosts/{hostId}/suppressions`.\n *\n * Both are keyed with `emailSuppressionKey`, one derivation, so the two reads\n * cannot disagree about which document to look for.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n assignmentReadout,\n grantEntriesAsRecorded,\n isOrgWideMember,\n readMarketingBasis,\n registerPluginApiRoute,\n resolveBrandingProfile,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailSuppressionKey,\n enrollListMember,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, not the barrel: this plugin's specs substitute the barrel\n// wholesale, and the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\n// The leaf for the same reason: which sites a group's opt-outs live on is the\n// rule, and a spec's barrel double must not be able to stand in for it.\nimport { consentGroupOptOutHosts } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n REPLY_BODY_MAX,\n REPLY_SUBJECT_MAX,\n composeReplyBody,\n replyRecipient,\n type ReplyRefusal,\n} from './model/reply-policy'\n\n/** Where a reply is stored, under the submission it answers. */\nexport const REPLIES_SUBCOLLECTION = 'replies'\n\n/** `context` on the send: the log label and the Resend attribution tag. */\nexport const REPLY_CONTEXT = 'inbox-reply'\n\n/** What a refusal says to the merchant. One line, naming the cause. */\nconst REFUSAL_MESSAGES: Record<ReplyRefusal, string> = {\n 'no-address': 'This submission has no email field, so there is nobody to reply to.',\n 'unroutable-address':\n 'The email address on this submission is not a valid address.',\n 'suppressed-platform':\n 'This address bounced or reported a message as spam, so it cannot be mailed.',\n 'suppressed-host':\n 'This address unsubscribed from this site, so it cannot be mailed.',\n}\n\n/**\n * Is this address suppressed, on either list?\n *\n * Order matters only for the reason reported, not the outcome. The platform\n * list is read first because it is the one that carries a bounce learned\n * anywhere — including on a send that named no site — and that is the more\n * useful thing to tell a merchant who is about to retype the message.\n *\n * Named for the ADDRESS rather than for either act, because both use it: a\n * reply must not go to a dead or complaining mailbox, and a list enrollment\n * must not put one on a standing audience. A second copy for the second act\n * would be two answers to \"may this address be mailed\".\n *\n * \"This site\" is its consent group: the site list is read on every site of\n * `group`, because somebody who unsubscribed from one site of a declared\n * group unsubscribed from the sender this site is. A group of one is the one\n * read it always was.\n */\nexport async function addressSuppression(\n hostId: string,\n email: string,\n firestore?: unknown,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<ReplyRefusal | null> {\n const hostIds =\n group && group.hostId === hostId ? consentGroupOptOutHosts(group) : [hostId]\n const key = emailSuppressionKey(email)\n // `emailSuppressionKey` returns null for an address it cannot key, and\n // `isEmailSuppressed` already answers `true` for that case. Treating it as\n // suppressed here keeps the two halves agreeing: an address we cannot key\n // is an address we cannot prove is safe to mail.\n if (!key) return 'suppressed-platform'\n if (await isEmailSuppressed(email, firestore)) return 'suppressed-platform'\n const db = (firestore ?? firebaseAdmin.app().firestore()) as any\n const docs = await Promise.all(\n hostIds.map((id) =>\n db.collection('hosts').doc(id).collection('suppressions').doc(key).get(),\n ),\n )\n return docs.some((doc) => doc.exists) ? 'suppressed-host' : null\n}\n\n/**\n * Reply to one submission.\n *\n * Body: `{ hostId, submissionId, subject, message }`. The recipient is\n * deliberately absent — it is read off the stored submission, so this route\n * cannot be pointed at an address of the caller's choosing.\n */\nexport const inboxReplyHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n const subject = String(req.body?.subject ?? '')\n .trim()\n .slice(0, REPLY_SUBJECT_MAX)\n const message = String(req.body?.message ?? '')\n .trim()\n .slice(0, REPLY_BODY_MAX)\n if (!hostId || !submissionId) {\n return res.status(400).json({ error: 'Missing hostId or submissionId' })\n }\n if (!subject || !message) {\n return res.status(400).json({ error: 'Missing subject or message' })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n // The address the reply goes back to, and the only thing that makes this\n // send worth making. A console account with no address on it would put\n // the customer's answer nowhere, so it refuses rather than sending a\n // message that invites a reply into a void.\n const replyTo = String(decoded.email ?? '')\n if (!replyTo) {\n return res.status(400).json({\n error:\n 'Your account has no email address, so a reply would have nowhere to come back to.',\n })\n }\n\n const submissionRef = hostRef\n .collection('formSubmissions')\n .doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return res.status(404).json({ error: 'Unknown submission' })\n }\n\n const fields = (submission.get('fields') ?? {}) as Record<string, unknown>\n const recipient = replyRecipient(fields)\n if ('refusal' in recipient) {\n return res\n .status(422)\n .json({ error: REFUSAL_MESSAGES[recipient.refusal], reason: recipient.refusal })\n }\n\n // The org once, for the sender's consent group here and its brand below.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const suppressed = await addressSuppression(\n hostId,\n recipient.email,\n undefined,\n await consentGroupForSite(\n hostId,\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n ),\n )\n if (suppressed) {\n return res\n .status(409)\n .json({ error: REFUSAL_MESSAGES[suppressed], reason: suppressed })\n }\n\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? '',\n )\n const branding = resolveBrandingProfile(owner?.org as never)\n\n // No `html` is passed on purpose. `sendEmail` synthesizes the HTML part\n // from `text`, which is the single place that guarantee is enforced; a\n // hand-built one here would be a second place it can be forgotten.\n // No `priority` either: absent, it resolves to transactional, which the\n // platform governor may never refuse. That is the correct class — a reply\n // is a person answering a person, and it cannot be retried by a sweep.\n const result = await sendEmail({\n to: recipient.email,\n subject,\n text: composeReplyBody({ message, fields, siteName }),\n replyTo,\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: REPLY_CONTEXT,\n })\n\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reply could not be sent.',\n reason: (result as { reason?: string }).reason ?? 'unknown',\n })\n }\n\n // Metered as transactional, so it enters the cost meter and never the\n // campaign meter a plan limit can refuse.\n void meterHostEmail(hostId, 1, 'transactional')\n\n const sentAtMs = Date.now()\n const replyRef = await submissionRef\n .collection(REPLIES_SUBCOLLECTION)\n .add({\n to: recipient.email,\n subject,\n // The merchant's own words, not the composed wire body. The quote and\n // the attribution line are rendered from the submission every time, so\n // storing them would be storing a copy of a document one field away.\n message,\n replyTo,\n fromName: branding.fromName,\n sentByUid: decoded.uid,\n providerMessageId: (result as { id?: string | null }).id ?? null,\n sentAtMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n // A replied submission is a handled one, so it stops being unread in the\n // same write that records the reply. `repliedAtMs` is what the row reads;\n // the count of replies is a subcollection read the list must not make.\n await submissionRef.set(\n { read: true, repliedAtMs: sentAtMs },\n { merge: true },\n )\n\n return res.status(200).json({\n sent: true,\n replyId: replyRef.id,\n to: recipient.email,\n replyTo,\n sentAtMs,\n })\n } catch (error) {\n console.error('[inbox] reply failed', error)\n return res.status(500).json({ error: 'The reply could not be sent.' })\n }\n}\n\n/** Where an assignment is recorded, under the submission that occasioned it. */\nexport const LIST_ASSIGNMENTS_SUBCOLLECTION = 'listAssignments'\n\n/** The `source` stamped on a member enrolled from the Inbox. */\nexport const ASSIGNMENT_SOURCE = 'inbox'\n\n/**\n * How many lists the picker offers.\n *\n * A ceiling on the READ, so a merchant with an unusual number of lists costs\n * one bounded query rather than a scan, and the response says when it is a\n * floor rather than presenting a slice as the whole set.\n */\nexport const LIST_OPTIONS_LIMIT = 100\n\n/** Everything both list-assignment handlers need, or the refusal to send. */\ntype AssignmentContext =\n | {\n ok: true\n uid: string\n orgId: string\n firestore: FirebaseFirestore.Firestore\n submissionRef: FirebaseFirestore.DocumentReference\n email: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, about which submission, and for which person.\n *\n * ## Two gates, not one\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the security rules\n * put both behind `isOrgWideMember()` — an editor invited to ONE site is an\n * org member with `allHosts: false`, and gating an org-wide write on the host\n * role alone would let a single-site collaborator enroll people into an\n * audience every other site in the org can mail. The Admin SDK evaluates no\n * rules, so this route is the enforcement rather than an echo of it.\n *\n * ## The address is read off the submission\n *\n * Through `replyRecipient`, the same resolver the reply and the Inbox row use.\n * A `to` in the request body would let a site editor enroll an address of\n * their choosing into a marketing audience, which is the same defect the\n * reply handler refuses for the same reason.\n */\nasync function resolveAssignmentContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<AssignmentContext> {\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n if (!hostId || !submissionId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or submissionId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String((await getOrgForHost(hostId).catch(() => null))?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so adding ' +\n 'someone to one needs organization-wide access rather than access ' +\n 'to this site.',\n },\n }\n }\n\n const submissionRef = hostRef.collection('formSubmissions').doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown submission' } }\n }\n const recipient = replyRecipient(\n (submission.get('fields') ?? {}) as Record<string, unknown>,\n )\n if ('refusal' in recipient) {\n return {\n ok: false,\n status: 422,\n body: {\n error: ASSIGNMENT_REFUSAL_MESSAGES[recipient.refusal],\n reason: recipient.refusal,\n },\n }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n orgId,\n firestore,\n submissionRef,\n email: recipient.email,\n }\n}\n\n/**\n * The person's own consent facts, read off the org contact for this address.\n *\n * The CRM record is where a refusal lives: `marketingConsent` is written\n * `false` by exactly one path in the product and it writes a contact. So this\n * is the read that makes `declined` mean something at enrollment time, and an\n * absent contact is honestly `unrecorded` rather than a reason to guess.\n *\n * Read UNSCOPED, deliberately. `scopedToHost` narrows an org collection to\n * what one site may see, and a refusal filtered out by that narrowing is a\n * refusal this route would then step over — the failure mode is mailing\n * somebody who said no. It is safe here because the caller has already been\n * proved an org-wide member, which is the tier the rules grant the whole\n * org's contacts to.\n *\n * Through the org's address index (AGL-2633), for the same failure mode\n * from the other side: a refusal recorded on a record that was later\n * merged into another is the survivor's refusal, and the sender's address\n * may be the one that became an alternate.\n *\n * The person's grants come back beside the verdict, off the same document\n * (AGL-3320): a pass-through carries them onto the membership as the record\n * holds them, rather than re-recording the opt-in over the group as it\n * stands today.\n */\nasync function storedConsentForAddress(\n hostId: string,\n group: ConsentGroup,\n email: string,\n): Promise<{\n stored: MarketingConsentRecord\n grants: Record<string, Record<string, unknown>>\n}> {\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const found = await findContactByEmail(contacts, email)\n const data = found ? (found.data() as Record<string, unknown>) : null\n return {\n stored: readMarketingBasis(data, group),\n grants: grantEntriesAsRecorded(data, group),\n }\n } catch (error) {\n console.error('[inbox] consent lookup failed', error)\n /*\n * FAILS TO `declined`, which reads oddly until you name the alternative.\n * A throwing read cannot say the person consented and cannot say they\n * refused; the question is which way the unknown should fall. `unrecorded`\n * would leave the attestation control on screen and let the merchant add\n * somebody whose stored refusal this route simply failed to see, and no\n * later surface would ever revisit it. A refusal costs a retry.\n */\n return {\n stored: {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody, because nobody asserted this: it is what a\n // failed read falls back to, not a refusal anyone recorded. Claiming\n // `'person'` here would put a refusal in the audit trail that the\n // person never made.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n },\n grants: {},\n }\n }\n}\n\n/**\n * `POST inbox/list-options` — what the merchant may do with this sender.\n *\n * Reads only. It exists because the answer needs three things the browser\n * cannot have: the org's lists (rules put them behind org-wide membership,\n * which the acting console session may not hold), the person's consent record\n * (an org contact, same gate), and both suppression lists. Computing any of\n * it client-side would be a second copy of the rule, on the surface whose\n * whole job is to tell the merchant the truth about what is about to happen.\n *\n * Reached by an explicit expansion in the reader, never on mount: it is three\n * reads and a bounded query, and paying them once per opened submission would\n * charge every merchant who never touches lists.\n */\nexport const inboxListOptionsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppression = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n const { stored } = await storedConsentForAddress(\n hostId,\n group,\n context.email,\n )\n const readout = assignmentReadout({ stored, suppression })\n\n const listsRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n /*\n * Ordered by document id, which is the one key every list has.\n * `limit()` alone answers in that order anyway but does not SAY so, and a\n * page of results whose order is an implementation detail is the shape\n * that turned an audience into a random sample. Ordering by `name` would\n * be worse than either: `orderBy` drops documents missing the field, so a\n * list created without a name would vanish from its own picker.\n */\n const snapshot = await listsRef\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(LIST_OPTIONS_LIMIT + 1)\n .get()\n const lists = snapshot.docs\n .slice(0, LIST_OPTIONS_LIMIT)\n .map((doc: FirebaseFirestore.QueryDocumentSnapshot) => ({\n id: doc.id,\n name: String(doc.get('name') ?? doc.id),\n }))\n\n return res.status(200).json({\n to: context.email,\n lists,\n listsTruncated: snapshot.docs.length > LIST_OPTIONS_LIMIT,\n basis: stored.basis,\n basisAtMs: stored.basisAtMs,\n ...readout,\n })\n } catch (error) {\n console.error('[inbox] list options failed', error)\n return res.status(500).json({ error: 'The lists could not be read.' })\n }\n}\n\n/**\n * `POST inbox/assign-list` — put this sender on a marketing list.\n *\n * Body: `{ hostId, submissionId, listId, attestConsent? }`. `attestConsent` is\n * the merchant STATING that they have this person's permission; it is not a\n * way to name a basis, because the pass-through basis is derived server-side\n * from the person's own record.\n *\n * ## What this act is, and what it is not\n *\n * It is marketing enrollment: the only consumer of a list is a campaign. It\n * is therefore kept entirely separate from the reply — different route,\n * different card in the UI, different record — and replying enrolls nobody.\n * The reverse holds too: enrolling somebody sends them nothing, meters\n * nothing, and is not a promise that they are still mailable when a campaign\n * eventually runs. Suppression is consulted again at send time, in\n * `filterSendableForHost`, because an address can be suppressed the day after\n * it is enrolled and an enrollment-time check that licensed every later send\n * would be a check that passes once and pays out forever.\n */\nexport const inboxAssignListHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listId = String(req.body?.listId ?? '')\n if (!listId) return res.status(400).json({ error: 'Missing listId' })\n const attested = req.body?.attestConsent === true\n\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n const refuse = (status: number, reason: AssignmentRefusal) =>\n res\n .status(status)\n .json({ error: ASSIGNMENT_REFUSAL_MESSAGES[reason], reason })\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppressed = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n if (suppressed) return refuse(409, suppressed)\n\n const listRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown list' })\n }\n\n const { stored, grants } = await storedConsentForAddress(\n hostId,\n group,\n context.email,\n )\n const nowMs = Date.now()\n const decision = assignmentBasis({\n stored,\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n // 409 for a refusal that no answer can change, 422 for the one the\n // merchant can answer by asserting a basis. The distinction is what\n // lets the UI offer the attestation control on exactly one of them.\n return refuse(decision.refusal === 'declined' ? 409 : 422, decision.refusal)\n }\n\n const enrollment = await enrollListMember({\n listRef,\n group,\n email: context.email,\n source: ASSIGNMENT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own rows\n // away when a person stops matching, and a decision somebody made by\n // hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in' ? { grantEntries: grants } : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal that the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds the\n * row, so it is the backstop for every enrollment route; reaching it\n * here means the two records disagree, and the refusal wins.\n */\n return refuse(409, enrollment.refusal === 'declined' ? 'declined' : 'no-address')\n }\n\n /*\n * The attestation trail, beside the reply record and for the same reason:\n * a support question about a marketing list starts with \"who put this\n * person on it, and on what basis\". The member document carries the basis\n * so the send-time join can read it; this carries the same facts where\n * the act happened, which is the only place that survives the person\n * later being removed from the list.\n */\n await context.submissionRef\n .collection(LIST_ASSIGNMENTS_SUBCOLLECTION)\n .add({\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n basisAtMs: decision.atMs,\n assertedByUid: decision.byUid,\n addedByUid: context.uid,\n addedAtMs: nowMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n enrolled: true,\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n })\n } catch (error) {\n console.error('[inbox] list assignment failed', error)\n return res.status(500).json({ error: 'The list assignment failed.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`. Each is reached by a person pressing a button in\n * a browser, so the visitor limiter's per-(site, IP) budget is far above any\n * real use of them and is the right ceiling for surfaces that put mail on the\n * wire or a person into a marketing audience.\n */\nexport function registerInboxConsoleApi(): void {\n registerPluginApiRoute('inbox/reply', inboxReplyHandler)\n registerPluginApiRoute('inbox/list-options', inboxListOptionsHandler)\n registerPluginApiRoute('inbox/assign-list', inboxAssignListHandler)\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","assignmentReadout","grantEntriesAsRecorded","isOrgWideMember","readMarketingBasis","registerPluginApiRoute","resolveBrandingProfile","emailSuppressionKey","enrollListMember","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","resolveOrgMembership","findContactByEmail","consentGroupOptOutHosts","sendEmail","FieldValue","REPLY_BODY_MAX","REPLY_SUBJECT_MAX","composeReplyBody","replyRecipient","REPLIES_SUBCOLLECTION","REPLY_CONTEXT","REFUSAL_MESSAGES","addressSuppression","hostId","email","firestore","group","hostIds","key","db","app","docs","Promise","all","map","id","collection","doc","get","some","exists","inboxReplyHandler","req","res","method","status","json","error","String","body","submissionId","subject","trim","slice","message","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","decoded","submission","auth","verifyIdToken","hostRef","memberRole","uid","replyTo","submissionRef","fields","recipient","refusal","reason","owner","catch","suppressed","org","siteName","branding","result","to","text","fromName","sendingIdentity","audience","context","sent","sentAtMs","Date","now","replyRef","add","sentByUid","providerMessageId","createdAt","serverTimestamp","set","read","repliedAtMs","merge","replyId","console","LIST_ASSIGNMENTS_SUBCOLLECTION","ASSIGNMENT_SOURCE","LIST_OPTIONS_LIMIT","resolveAssignmentContext","ok","orgId","membership","member","orgWideWriter","role","orgSuspended","storedConsentForAddress","contacts","found","data","stored","grants","basis","assertedBy","source","basisAtMs","capturedAtMs","inboxListOptionsHandler","suppression","readout","listsRef","snapshot","orderBy","FieldPath","documentId","limit","lists","name","listsTruncated","inboxAssignListHandler","listId","attested","attestConsent","listSnapshot","refuse","listRef","nowMs","decision","actingUid","enrollment","via","consent","grantEntries","enrolled","listName","memberId","atMs","assertedByUid","byUid","addedByUid","addedAtMs","registerInboxConsoleApi"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4EC,GAED,SACEA,2BAA2B,EAC3BC,eAAe,EACfC,iBAAiB,EACjBC,sBAAsB,EACtBC,eAAe,EACfC,kBAAkB,EAClBC,sBAAsB,EACtBC,sBAAsB,QAKjB,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,gBAAgB,EAChBC,aAAa,EACbC,aAAa,EACbC,mBAAmB,EACnBC,iBAAiB,EACjBC,cAAc,EACdC,mBAAmB,EACnBC,wBAAwB,EACxBC,oBAAoB,QACf,2BAA0B;AACjC,sEAAsE;AACtE,wEAAwE;AACxE,SAASC,kBAAkB,QAAQ,sDAAqD;AACxF,8EAA8E;AAC9E,wEAAwE;AACxE,SAASC,uBAAuB,QAAQ,wCAAuC;AAC/E,SAASC,SAAS,QAAQ,2BAA0B;AACpD,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,cAAc,EACdC,iBAAiB,EACjBC,gBAAgB,EAChBC,cAAc,QAET,0BAAsB;AAE7B,8DAA8D,GAC9D,OAAO,MAAMC,wBAAwB,UAAS;AAE9C,yEAAyE,GACzE,OAAO,MAAMC,gBAAgB,cAAa;AAE1C,qEAAqE,GACrE,MAAMC,mBAAiD;IACrD,cAAc;IACd,sBACE;IACF,uBACE;IACF,mBACE;AACJ;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeC,mBACpBC,MAAc,EACdC,KAAa,EACbC,SAAmB,EACnBC,KAAuD;IAEvD,MAAMC,UACJD,SAASA,MAAMH,MAAM,KAAKA,SAASX,wBAAwBc,SAAS;QAACH;KAAO;IAC9E,MAAMK,MAAM3B,oBAAoBuB;IAChC,uEAAuE;IACvE,2EAA2E;IAC3E,0EAA0E;IAC1E,iDAAiD;IACjD,IAAI,CAACI,KAAK,OAAO;IACjB,IAAI,MAAMtB,kBAAkBkB,OAAOC,YAAY,OAAO;IACtD,MAAMI,KAAMJ,oBAAAA,YAAatB,cAAc2B,GAAG,GAAGL,SAAS;IACtD,MAAMM,OAAO,MAAMC,QAAQC,GAAG,CAC5BN,QAAQO,GAAG,CAAC,CAACC,KACXN,GAAGO,UAAU,CAAC,SAASC,GAAG,CAACF,IAAIC,UAAU,CAAC,gBAAgBC,GAAG,CAACT,KAAKU,GAAG;IAG1E,OAAOP,KAAKQ,IAAI,CAAC,CAACF,MAAQA,IAAIG,MAAM,IAAI,oBAAoB;AAC9D;AAEA;;;;;;CAMC,GACD,OAAO,MAAMC,oBAAsC,OAAOC,KAAKC;mCAoBhCD;QAfPA,WACMA,YACLA,YAGAA;IATvB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IAEA,MAAMxB,SAASyB,gBAAON,YAAAA,IAAIO,IAAI,qBAARP,UAAUnB,MAAM,mBAAI;IAC1C,MAAM2B,eAAeF,iBAAON,aAAAA,IAAIO,IAAI,qBAARP,WAAUQ,YAAY,oBAAI;IACtD,MAAMC,UAAUH,iBAAON,aAAAA,IAAIO,IAAI,qBAARP,WAAUS,OAAO,oBAAI,IACzCC,IAAI,GACJC,KAAK,CAAC,GAAGrC;IACZ,MAAMsC,UAAUN,iBAAON,aAAAA,IAAIO,IAAI,qBAARP,WAAUY,OAAO,oBAAI,IACzCF,IAAI,GACJC,KAAK,CAAC,GAAGtC;IACZ,IAAI,CAACQ,UAAU,CAAC2B,cAAc;QAC5B,OAAOP,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiC;IACxE;IACA,IAAI,CAACI,WAAW,CAACG,SAAS;QACxB,OAAOX,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA6B;IACpE;IAEA,MAAMQ,gBAAgBP,QAAON,6BAAAA,IAAIc,OAAO,CAACD,aAAa,YAAzBb,6BAA6B;IAC1D,MAAMe,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcF,KAAK,CAAC,UAAUM,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAOd,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAI;YAQkBc,mBASGC,gBAgBPC,wBA0BdF,OAAAA,oBA6CqB;QAvGvB,MAAMC,UAAU,MAAM3D,cAAc2B,GAAG,GAAGkC,IAAI,GAAGC,aAAa,CAACR;QAC/D,MAAMhC,YAAYtB,cAAc2B,GAAG,GAAGL,SAAS;QAC/C,MAAMyC,UAAUzC,UAAUW,UAAU,CAAC,SAASC,GAAG,CAACd;QAClD,MAAMsC,eAAe,MAAMK,QAAQ5B,GAAG;QACtC,IAAI,CAACuB,aAAarB,MAAM,EAAE;YACxB,OAAOG,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;QACtD;QACA,MAAMoB,aAAa,EAACN,oBAAAA,aAAavB,GAAG,CAAC,0BAAjBuB,oBAAmC,CAAC,EAAE,CAACC,QAAQM,GAAG,CAAC;QACvE,IAAID,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAOxB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA6B;QACpE;QAEA,yEAAyE;QACzE,uEAAuE;QACvE,qEAAqE;QACrE,4CAA4C;QAC5C,MAAMsB,UAAUrB,QAAOc,iBAAAA,QAAQtC,KAAK,YAAbsC,iBAAiB;QACxC,IAAI,CAACO,SAAS;YACZ,OAAO1B,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE;YACJ;QACF;QAEA,MAAMuB,gBAAgBJ,QACnB9B,UAAU,CAAC,mBACXC,GAAG,CAACa;QACP,MAAMa,aAAa,MAAMO,cAAchC,GAAG;QAC1C,IAAI,CAACyB,WAAWvB,MAAM,EAAE;YACtB,OAAOG,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAqB;QAC5D;QAEA,MAAMwB,UAAUR,kBAAAA,WAAWzB,GAAG,CAAC,qBAAfyB,kBAA4B,CAAC;QAC7C,MAAMS,YAAYtD,eAAeqD;QACjC,IAAI,aAAaC,WAAW;YAC1B,OAAO7B,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO1B,gBAAgB,CAACmD,UAAUC,OAAO,CAAC;gBAAEC,QAAQF,UAAUC,OAAO;YAAC;QAClF;QAEA,yEAAyE;QACzE,MAAME,QAAQ,MAAMvE,cAAcmB,QAAQqD,KAAK,CAAC,IAAM;QACtD,MAAMC,aAAa,MAAMvD,mBACvBC,QACAiD,UAAUhD,KAAK,EACfoC,WACA,MAAMpD,oBACJe,iBACCoD,yBAAAA,MAAOG,GAAG,oBAA4C;QAG3D,IAAID,YAAY;YACd,OAAOlC,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO1B,gBAAgB,CAACwD,WAAW;gBAAEH,QAAQG;YAAW;QACpE;QAEA,MAAME,WAAW/B,QACfa,SAAAA,qBAAAA,aAAavB,GAAG,CAAC,0BAAjBuB,qBAAmCA,aAAavB,GAAG,CAAC,wBAApDuB,QAAoE;QAEtE,MAAMmB,WAAWhF,uBAAuB2E,yBAAAA,MAAOG,GAAG;QAElD,wEAAwE;QACxE,uEAAuE;QACvE,mEAAmE;QACnE,wEAAwE;QACxE,0EAA0E;QAC1E,uEAAuE;QACvE,MAAMG,SAAS,MAAMpE,UAAU;YAC7BqE,IAAIV,UAAUhD,KAAK;YACnB2B;YACAgC,MAAMlE,iBAAiB;gBAAEqC;gBAASiB;gBAAQQ;YAAS;YACnDV;YACAe,UAAUJ,SAASI,QAAQ;YAC3BC,iBAAiB,MAAMhF,oBAAoBkB;YAC3C+D,UAAU;YACVC,SAASnE;QACX;QAEA,IAAI,CAAC6D,OAAOO,IAAI,EAAE;gBAGN;YAFV,OAAO7C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OAAO;gBACP2B,MAAM,GAAE,iBAAA,AAACO,OAA+BP,MAAM,YAAtC,iBAA0C;YACpD;QACF;QAEA,sEAAsE;QACtE,0CAA0C;QAC1C,KAAKnE,eAAegB,QAAQ,GAAG;QAE/B,MAAMkE,WAAWC,KAAKC,GAAG;QACzB,MAAMC,WAAW,MAAMtB,cACpBlC,UAAU,CAACjB,uBACX0E,GAAG,CAAC;YACHX,IAAIV,UAAUhD,KAAK;YACnB2B;YACA,sEAAsE;YACtE,uEAAuE;YACvE,qEAAqE;YACrEG;YACAe;YACAe,UAAUJ,SAASI,QAAQ;YAC3BU,WAAWhC,QAAQM,GAAG;YACtB2B,iBAAiB,GAAE,aAAA,AAACd,OAAkC9C,EAAE,YAArC,aAAyC;YAC5DsD;YACAO,WAAWlF,WAAWmF,eAAe;QACvC;QAEF,yEAAyE;QACzE,0EAA0E;QAC1E,uEAAuE;QACvE,MAAM3B,cAAc4B,GAAG,CACrB;YAAEC,MAAM;YAAMC,aAAaX;QAAS,GACpC;YAAEY,OAAO;QAAK;QAGhB,OAAO1D,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1B0C,MAAM;YACNc,SAASV,SAASzD,EAAE;YACpB+C,IAAIV,UAAUhD,KAAK;YACnB6C;YACAoB;QACF;IACF,EAAE,OAAO1C,OAAO;QACdwD,QAAQxD,KAAK,CAAC,wBAAwBA;QACtC,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;IACtE;AACF,EAAC;AAED,8EAA8E,GAC9E,OAAO,MAAMyD,iCAAiC,kBAAiB;AAE/D,8DAA8D,GAC9D,OAAO,MAAMC,oBAAoB,QAAO;AAExC;;;;;;CAMC,GACD,OAAO,MAAMC,qBAAqB,IAAG;AAcrC;;;;;;;;;;;;;;;;;;;CAmBC,GACD,eAAeC,yBACbjE,GAAoC;qBAYPA,4BAeTmB,0BA8CjBE;QAvEmBrB,WACMA,YAiCN;IAlCtB,MAAMnB,SAASyB,gBAAON,YAAAA,IAAIO,IAAI,qBAARP,UAAUnB,MAAM,mBAAI;IAC1C,MAAM2B,eAAeF,iBAAON,aAAAA,IAAIO,IAAI,qBAARP,WAAUQ,YAAY,oBAAI;IACtD,IAAI,CAAC3B,UAAU,CAAC2B,cAAc;QAC5B,OAAO;YACL0D,IAAI;YACJ/D,QAAQ;YACRI,MAAM;gBAAEF,OAAO;YAAiC;QAClD;IACF;IAEA,MAAMQ,gBAAgBP,QAAON,6BAAAA,IAAIc,OAAO,CAACD,aAAa,YAAzBb,6BAA6B;IAC1D,MAAMe,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcF,KAAK,CAAC,UAAUM,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS;QACZ,OAAO;YAAEmD,IAAI;YAAO/D,QAAQ;YAAKI,MAAM;gBAAEF,OAAO;YAAkB;QAAE;IACtE;IAEA,MAAMe,UAAU,MAAM3D,cAAc2B,GAAG,GAAGkC,IAAI,GAAGC,aAAa,CAACR;IAC/D,MAAMhC,YAAYtB,cAAc2B,GAAG,GAAGL,SAAS;IAC/C,MAAMyC,UAAUzC,UAAUW,UAAU,CAAC,SAASC,GAAG,CAACd;IAClD,MAAMsC,eAAe,MAAMK,QAAQ5B,GAAG;IACtC,IAAI,CAACuB,aAAarB,MAAM,EAAE;QACxB,OAAO;YAAEoE,IAAI;YAAO/D,QAAQ;YAAKI,MAAM;gBAAEF,OAAO;YAAe;QAAE;IACnE;IACA,MAAMoB,aAAa,EAACN,oBAAAA,aAAavB,GAAG,CAAC,0BAAjBuB,oBAAmC,CAAC,EAAE,CAACC,QAAQM,GAAG,CAAC;IACvE,IAAID,eAAe,WAAWA,eAAe,UAAU;QACrD,OAAO;YACLyC,IAAI;YACJ/D,QAAQ;YACRI,MAAM;gBAAEF,OAAO;YAA6B;QAC9C;IACF;IAEA,MAAM8D,QAAQ7D,iBAAQ,QAAA,MAAM5C,cAAcmB,QAAQqD,KAAK,CAAC,IAAM,0BAAzC,AAAC,MAAgDiC,KAAK,oBAAI;IAC/E,IAAI,CAACA,OAAO;QACV,OAAO;YACLD,IAAI;YACJ/D,QAAQ;YACRI,MAAM;gBAAEF,OAAO;YAAqD;QACtE;IACF;IACA,MAAM+D,aAAa,MAAMpG,qBAAqBoD,QAAQM,GAAG,EAAEyC,OAAOjC,KAAK,CACrE,IAAM;IAER,MAAMmC,SAASD,8BAAAA,WAAYC,MAAM;IACjC,MAAMC,gBACJnH,gBAAgBkH,WACfA,CAAAA,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,WAChBF,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,WACjBF,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,QAAO,KAC1B,CAACF,0BAAD,AAACA,OAAmDG,YAAY,MAAK;IACvE,IAAI,CAACF,eAAe;QAClB,OAAO;YACLJ,IAAI;YACJ/D,QAAQ;YACRI,MAAM;gBACJF,OACE,iEACA,sEACA;YACJ;QACF;IACF;IAEA,MAAMuB,gBAAgBJ,QAAQ9B,UAAU,CAAC,mBAAmBC,GAAG,CAACa;IAChE,MAAMa,aAAa,MAAMO,cAAchC,GAAG;IAC1C,IAAI,CAACyB,WAAWvB,MAAM,EAAE;QACtB,OAAO;YAAEoE,IAAI;YAAO/D,QAAQ;YAAKI,MAAM;gBAAEF,OAAO;YAAqB;QAAE;IACzE;IACA,MAAMyB,YAAYtD,gBACf6C,kBAAAA,WAAWzB,GAAG,CAAC,qBAAfyB,kBAA4B,CAAC;IAEhC,IAAI,aAAaS,WAAW;QAC1B,OAAO;YACLoC,IAAI;YACJ/D,QAAQ;YACRI,MAAM;gBACJF,OAAOtD,2BAA2B,CAAC+E,UAAUC,OAAO,CAAC;gBACrDC,QAAQF,UAAUC,OAAO;YAC3B;QACF;IACF;IAEA,OAAO;QACLmC,IAAI;QACJxC,KAAKN,QAAQM,GAAG;QAChByC;QACApF;QACA6C;QACA9C,OAAOgD,UAAUhD,KAAK;IACxB;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,eAAe2F,wBACb5F,MAAc,EACdG,KAAmB,EACnBF,KAAa;IAKb,IAAI;QACF,MAAM4F,WAAW,MAAM3G,yBAAyBc,QAAQ;QACxD,MAAM8F,QAAQ,MAAM1G,mBAAmByG,UAAU5F;QACjD,MAAM8F,OAAOD,QAASA,MAAMC,IAAI,KAAiC;QACjE,OAAO;YACLC,QAAQzH,mBAAmBwH,MAAM5F;YACjC8F,QAAQ5H,uBAAuB0H,MAAM5F;QACvC;IACF,EAAE,OAAOqB,OAAO;QACdwD,QAAQxD,KAAK,CAAC,iCAAiCA;QAC/C;;;;;;;KAOC,GACD,OAAO;YACLwE,QAAQ,aACHzH,mBAAmB,MAAM4B;gBAC5B+F,OAAO;gBACP,mEAAmE;gBACnE,qEAAqE;gBACrE,kEAAkE;gBAClE,qBAAqB;gBACrBC,YAAY;gBACZC,QAAQ;gBACRC,WAAW;gBACXC,cAAc;;YAEhBL,QAAQ,CAAC;QACX;IACF;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMM,0BAA4C,OAAOpF,KAAKC;IACnE,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,IAAI;;YAKoBL;QAJtB,MAAM6C,UAAU,MAAMoB,yBAAyBjE;QAC/C,IAAI6C,QAAQqB,EAAE,KAAK,OAAO;YACxB,OAAOjE,IAAIE,MAAM,CAAC0C,QAAQ1C,MAAM,EAAEC,IAAI,CAACyC,QAAQtC,IAAI;QACrD;QACA,MAAM1B,SAASyB,gBAAON,YAAAA,IAAIO,IAAI,qBAARP,UAAUnB,MAAM,mBAAI;QAE1C,qEAAqE;QACrE,sEAAsE;QACtE,oDAAoD;QACpD,MAAMG,QAAQ,MAAMlB,oBAAoBe;QACxC,MAAMwG,cAAc,MAAMzG,mBACxBC,QACAgE,QAAQ/D,KAAK,EACboC,WACAlC;QAEF,MAAM,EAAE6F,MAAM,EAAE,GAAG,MAAMJ,wBACvB5F,QACAG,OACA6D,QAAQ/D,KAAK;QAEf,MAAMwG,UAAUrI,kBAAkB;YAAE4H;YAAQQ;QAAY;QAExD,MAAME,WAAW1C,QAAQ9D,SAAS,CAC/BW,UAAU,CAAC,QACXC,GAAG,CAACkD,QAAQsB,KAAK,EACjBzE,UAAU,CAAC;QACd;;;;;;;KAOC,GACD,MAAM8F,WAAW,MAAMD,SACpBE,OAAO,CAAChI,cAAcsB,SAAS,CAAC2G,SAAS,CAACC,UAAU,IACpDC,KAAK,CAAC5B,qBAAqB,GAC3BpE,GAAG;QACN,MAAMiG,QAAQL,SAASnG,IAAI,CACxBsB,KAAK,CAAC,GAAGqD,oBACTxE,GAAG,CAAC,CAACG;gBAESA;mBAFyC;gBACtDF,IAAIE,IAAIF,EAAE;gBACVqG,MAAMxF,QAAOX,WAAAA,IAAIC,GAAG,CAAC,mBAARD,WAAmBA,IAAIF,EAAE;YACxC;;QAEF,OAAOQ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BoC,IAAIK,QAAQ/D,KAAK;YACjB+G;YACAE,gBAAgBP,SAASnG,IAAI,CAAC4B,MAAM,GAAG+C;YACvCe,OAAOF,OAAOE,KAAK;YACnBG,WAAWL,OAAOK,SAAS;WACxBI;IAEP,EAAE,OAAOjF,OAAO;QACdwD,QAAQxD,KAAK,CAAC,+BAA+BA;QAC7C,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;IACtE;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,MAAM2F,yBAA2C,OAAOhG,KAAKC;;QAI5CD,WAELA;IALjB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAM4F,SAAS3F,gBAAON,YAAAA,IAAIO,IAAI,qBAARP,UAAUiG,MAAM,mBAAI;IAC1C,IAAI,CAACA,QAAQ,OAAOhG,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAiB;IACnE,MAAM6F,WAAWlG,EAAAA,aAAAA,IAAIO,IAAI,qBAARP,WAAUmG,aAAa,MAAK;IAE7C,IAAI;mBA4FmBC,mBAcFA;YArGGpG;QAJtB,MAAM6C,UAAU,MAAMoB,yBAAyBjE;QAC/C,IAAI6C,QAAQqB,EAAE,KAAK,OAAO;YACxB,OAAOjE,IAAIE,MAAM,CAAC0C,QAAQ1C,MAAM,EAAEC,IAAI,CAACyC,QAAQtC,IAAI;QACrD;QACA,MAAM1B,SAASyB,iBAAON,aAAAA,IAAIO,IAAI,qBAARP,WAAUnB,MAAM,oBAAI;QAE1C,MAAMwH,SAAS,CAAClG,QAAgB6B,SAC9B/B,IACGE,MAAM,CAACA,QACPC,IAAI,CAAC;gBAAEC,OAAOtD,2BAA2B,CAACiF,OAAO;gBAAEA;YAAO;QAE/D,qEAAqE;QACrE,sEAAsE;QACtE,oDAAoD;QACpD,MAAMhD,QAAQ,MAAMlB,oBAAoBe;QACxC,MAAMsD,aAAa,MAAMvD,mBACvBC,QACAgE,QAAQ/D,KAAK,EACboC,WACAlC;QAEF,IAAImD,YAAY,OAAOkE,OAAO,KAAKlE;QAEnC,MAAMmE,UAAUzD,QAAQ9D,SAAS,CAC9BW,UAAU,CAAC,QACXC,GAAG,CAACkD,QAAQsB,KAAK,EACjBzE,UAAU,CAAC,SACXC,GAAG,CAACsG;QACP,MAAMG,eAAe,MAAME,QAAQ1G,GAAG;QACtC,qEAAqE;QACrE,iDAAiD;QACjD,IAAI,CAACwG,aAAatG,MAAM,EAAE;YACxB,OAAOG,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;QACtD;QAEA,MAAM,EAAEwE,MAAM,EAAEC,MAAM,EAAE,GAAG,MAAML,wBAC/B5F,QACAG,OACA6D,QAAQ/D,KAAK;QAEf,MAAMyH,QAAQvD,KAAKC,GAAG;QACtB,MAAMuD,WAAWxJ,gBAAgB;YAC/B6H;YACAqB;YACAO,WAAW5D,QAAQnB,GAAG;YACtB6E;QACF;QACA,IAAI,aAAaC,UAAU;YACzB,mEAAmE;YACnE,oEAAoE;YACpE,oEAAoE;YACpE,OAAOH,OAAOG,SAASzE,OAAO,KAAK,aAAa,MAAM,KAAKyE,SAASzE,OAAO;QAC7E;QAEA,MAAM2E,aAAa,MAAMlJ,iBAAiB;YACxC8I;YACAtH;YACAF,OAAO+D,QAAQ/D,KAAK;YACpBmG,QAAQlB;YACR,wEAAwE;YACxE,qEAAqE;YACrE,wCAAwC;YACxC4C,KAAK;YACLC,SAASJ;WAGLA,SAASzB,KAAK,KAAK,mBAAmB;YAAE8B,cAAc/B;QAAO,IAAI,CAAC;QAExE,IAAI4B,WAAWI,QAAQ,KAAK,OAAO;YACjC;;;;;OAKC,GACD,OAAOT,OAAO,KAAKK,WAAW3E,OAAO,KAAK,aAAa,aAAa;QACtE;QAEA;;;;;;;KAOC,GACD,MAAMc,QAAQjB,aAAa,CACxBlC,UAAU,CAACoE,gCACXX,GAAG,CAAC;YACHX,IAAIK,QAAQ/D,KAAK;YACjBmH;YACAc,UAAUzG,QAAO8F,oBAAAA,aAAaxG,GAAG,CAAC,mBAAjBwG,oBAA4BH;YAC7Ce,UAAUN,WAAWM,QAAQ;YAC7BjC,OAAOyB,SAASzB,KAAK;YACrBG,WAAWsB,SAASS,IAAI;YACxBC,eAAeV,SAASW,KAAK;YAC7BC,YAAYvE,QAAQnB,GAAG;YACvB2F,WAAWd;YACXjD,WAAWlF,WAAWmF,eAAe;QACvC;QAEF,OAAOtD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1B0G,UAAU;YACVtE,IAAIK,QAAQ/D,KAAK;YACjBmH;YACAc,UAAUzG,QAAO8F,qBAAAA,aAAaxG,GAAG,CAAC,mBAAjBwG,qBAA4BH;YAC7Ce,UAAUN,WAAWM,QAAQ;YAC7BjC,OAAOyB,SAASzB,KAAK;QACvB;IACF,EAAE,OAAO1E,OAAO;QACdwD,QAAQxD,KAAK,CAAC,kCAAkCA;QAChD,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;IACrE;AACF,EAAC;AAED;;;;;;;;CAQC,GACD,OAAO,SAASiH;IACdjK,uBAAuB,eAAe0C;IACtC1C,uBAAuB,sBAAsB+H;IAC7C/H,uBAAuB,qBAAqB2I;AAC9C"}