@aglyn/plugins-crm 1.0.0-beta.165 → 1.0.0-beta.166
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 +13 -13
- package/src/lib/components/contact-associations-card.js +7 -1
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/crm-console-page.js +12 -7
- package/src/lib/components/crm-console-page.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +4 -2
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +7 -1
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +1 -1
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +1 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +7 -1
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.d.ts +1 -2
- package/src/lib/components/leads-bulk-bar.js +8 -3
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +27 -15
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/reports/lead-counts.d.ts +16 -9
- package/src/lib/components/reports/lead-counts.js +29 -18
- package/src/lib/components/reports/lead-counts.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +44 -40
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/hooks/use-org-leads.d.ts +29 -32
- package/src/lib/hooks/use-org-leads.js +58 -68
- package/src/lib/hooks/use-org-leads.js.map +1 -1
- package/src/lib/model/crm-routes.d.ts +9 -6
- package/src/lib/model/crm-routes.js +9 -6
- package/src/lib/model/crm-routes.js.map +1 -1
- package/src/lib/server/capture-contact.js +11 -5
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +6 -2
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/email-send.js +3 -3
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/erase-person.js +25 -7
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.js +3 -2
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-create.js +8 -6
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/leads-import.js +2 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.js +10 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-facts.js +3 -3
- package/src/lib/server/record-facts.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-funnel-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n type Firestore,\n getCountFromServer,\n getDocs,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../../hooks/use-crm-org-mount'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename } from './report-format'\nimport { type CrmReportScope, reportCacheKey } from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport {\n type AggregateRead,\n useAggregateRead,\n useWindowRead,\n type WindowRead,\n} from './use-aggregate-read'\n\n/**\n * How many of the period's leads the funnel is placed from — the Leads\n * section's own window, over the period rather than over everything.\n *\n * The captured count is a server aggregate and never meets this bound; the\n * funnel needs each lead's status and reason, so it reads the period's\n * newest two hundred and says when there were more. At the organization\n * level the bound is PER SITE (AGL-2634): each site's window is its own,\n * and a site with a thousand leads must not crowd out a site with ten.\n */\nexport const LEAD_CEILING = 200\n/** How many reasons the bar list draws; the rest are counted beneath it. */\nconst REASONS_SHOWN = 8\n\n/** The CSV's columns: one file for both the statuses and the reasons. */\nconst COLUMNS = ['Kind', 'Label', 'Count', 'Share'] as const\n\ntype LeadRow = Record<string, unknown> & Aglyn.CrmLeadFields & { $id: string }\n\n/** The period's captured count, and the previous period's for the delta. */\ninterface CapturedFigures {\n current: number\n previous: number\n}\n\nexport interface LeadFunnelCardProps {\n report: CrmReportScope\n /**\n * The site whose leads are placed. A lead lives under its host, not the\n * org — `hosts/{hostId}/leads`, private by path, no `visibleTo` — so the\n * report's org scope cannot reach it and the card is told the site. At\n * the organization level `null` (AGL-2630): the card then reads every\n * site the mount lists, one window each (AGL-2634).\n */\n hostId: string | null\n}\n\nconst LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#lead-funnel',\n excerpt:\n 'Of the leads this site captured in the period, how many are ' +\n 'still new, being worked, qualified or unqualified — and the ' +\n 'reasons the unqualified ones were closed. Placed by when each ' +\n 'lead was first seen.',\n})\n\n/** One site's leads first seen in a range — a single-field range, no composite index. */\nfunction leadsBetween(firestore: Firestore, hostId: string, from: number, to: number) {\n return query(\n collection(firestore, 'hosts', hostId, 'leads'),\n where('firstSeenAtMs', '>=', from),\n where('firstSeenAtMs', '<', to),\n )\n}\n\nconst countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n/**\n * The lead funnel, at whichever level the report is mounted (AGL-2624,\n * AGL-2630, AGL-2634).\n *\n * Under a site: that site. At the organization level: every site the\n * mount lists, one window each, totaled — the org hub's reports total\n * every site, and the leads are no exception now that the fan-out is\n * bounded by the org's site list. With no sites to read the card says so\n * instead of guessing.\n */\nexport function LeadFunnelCard(props: LeadFunnelCardProps) {\n const { report, hostId } = props\n const mount = useCrmOrgMount()\n if (hostId) return <SiteLeadFunnelCard report={report} hostId={hostId} />\n if (mount && (!mount.hostsReady || mount.hosts.length > 0)) {\n return <OrgLeadFunnelCard report={report} />\n }\n return (\n <CardDisplay header={'Lead funnel'} help={LEAD_FUNNEL_HELP} contentGutterX contentGutterY>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {mount\n ? 'Leads live under a site, and this organization has no sites yet.'\n : 'Leads live under a site, and this report is mounted under none.'}\n </Typography>\n </CardDisplay>\n )\n}\n\n/**\n * The lead funnel (AGL-2624): of the leads this site captured in the\n * period, how many are still new, being worked, qualified or unqualified —\n * and why the unqualified ones were closed.\n *\n * A cohort by first sighting, followed to where each lead stands now,\n * because that is the question a period asks of leads — \"what became of\n * the ones we got\" — and because a lead's status has no history on the\n * document: only its current value. The window is a range on\n * `firstSeenAtMs`, which the capture door stamps once on creation and\n * never again, so a returning visitor's second form is not a second lead\n * in a second period. A single-field range needs no composite index.\n *\n * Read directly off `hosts/{hostId}/leads` under the site's own rules —\n * any member of the site may read it — the way the Leads section reads\n * it, so the funnel and the list can never disagree about who may see a\n * lead.\n */\nfunction SiteLeadFunnelCard(props: { report: CrmReportScope; hostId: string }) {\n const { report, hostId } = props\n const { range } = report\n const firestore = useFirestore()\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, hostId, range.from, range.to)),\n countOf(leadsBetween(firestore, hostId, range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:counts:${hostId}`) },\n )\n const window = useWindowRead<LeadRow>(\n () =>\n query(\n leadsBetween(firestore, hostId, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n LEAD_CEILING,\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:window:${hostId}`) },\n )\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n (figures !== null ? ` of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'This site’s leads could not be read.'}\n />\n )\n}\nSiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard'\n\n/**\n * THE CROSS-SITE FUNNEL (AGL-2634): every site of the organization, one\n * window each, totaled.\n *\n * The counts are one server aggregate per site per period, summed. The\n * windows are one bounded read per site — the site card's own query, the\n * same ceiling — merged newest-seen first and NOT cut again: the funnel\n * is a placement of leads by status, and cutting the merged list would\n * place a busy site's leads and drop a quiet site's. What the ceiling\n * bounds is the read per site, and the caption says so when any site had\n * more than its window. Held until the mount's site list has settled, so\n * a first round against a partial list is not answered and remembered.\n */\nfunction OrgLeadFunnelCard(props: { report: CrmReportScope }) {\n const { report } = props\n const { range } = report\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const hostIds = useMemo(\n () => (mount?.hostsReady ? mount.hosts.map((host) => host.id) : null),\n [mount],\n )\n const key = hostIds?.join('\\n') ?? ''\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n hostIds\n ? Promise.all(\n hostIds.map((hostId) =>\n Promise.all([\n countOf(leadsBetween(firestore, hostId, range.from, range.to)),\n countOf(leadsBetween(firestore, hostId, range.previousFrom, range.previousTo)),\n ]),\n ),\n ).then((pairs) =>\n pairs.reduce(\n (sum, [current, previous]) => ({\n current: sum.current + current,\n previous: sum.previous + previous,\n }),\n { current: 0, previous: 0 },\n ),\n )\n : null,\n [firestore, key, range],\n { cacheKey: reportCacheKey(report, `leads:counts:org:${key}`) },\n )\n const merged = useAggregateRead<{ rows: LeadRow[]; truncated: boolean }>(\n () =>\n hostIds\n ? Promise.all(\n hostIds.map((hostId) =>\n getDocs(\n query(\n leadsBetween(firestore, hostId, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n ).then((snapshot) =>\n ceilingedWindow(\n snapshot.docs.map(\n (document) =>\n ({ ...document.data(), $id: `${hostId}/${document.id}` }) as unknown as LeadRow,\n ),\n LEAD_CEILING,\n ),\n ),\n ),\n ).then((windows) => ({\n rows: windows\n .flatMap((window) => window.rows)\n .sort(\n (a, b) =>\n Number(b['firstSeenAtMs'] ?? 0) - Number(a['firstSeenAtMs'] ?? 0),\n ),\n truncated: windows.some((window) => window.truncated),\n }))\n : null,\n [firestore, key, range],\n { cacheKey: reportCacheKey(report, `leads:window:org:${key}`) },\n )\n const window = useMemo<WindowRead<LeadRow>>(\n () => ({\n rows: merged.value?.rows ?? [],\n truncated: merged.value?.truncated ?? false,\n status: merged.status,\n }),\n [merged],\n )\n const sites = hostIds?.length ?? 0\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n subheader={`Every site (${sites.toLocaleString()})`}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n ` across ${plural(sites, 'site')} — at most ${LEAD_CEILING.toLocaleString()} per site` +\n (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'Some site’s leads could not be read.'}\n />\n )\n}\nOrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard'\n\n/**\n * The funnel as drawn, whichever level fed it: four tiles, the statuses,\n * the reasons, the export. Pure over the two reads.\n */\nfunction LeadFunnelBody(props: {\n report: CrmReportScope\n subheader?: string\n captured: AggregateRead<CapturedFigures>\n window: WindowRead<LeadRow>\n caption?: string\n errorText: string\n}) {\n const { report, subheader, captured, window, caption, errorText } = props\n const { period, routes } = report\n const status = window.status\n\n const funnel = useMemo(() => Aglyn.leadFunnel(window.rows), [window])\n const read = status === 'success'\n const figures = captured.value\n const share = (count: number, of: number): string | null =>\n of > 0 ? percent(count / of) : null\n const reasons = funnel.reasons.slice(0, REASONS_SHOWN)\n const moreReasons = funnel.reasons.length - reasons.length\n\n return (\n <CardDisplay\n header={'Lead funnel'}\n subheader={subheader}\n help={LEAD_FUNNEL_HELP}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('leads')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open leads'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Leads captured'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? Aglyn.deltaPercent(figures.current, figures.previous) : null}\n note={'first seen in the period'}\n href={routes.section('leads')}\n />\n <ReportStatTile\n label={'Qualified'}\n value={read ? funnel.byStatus.qualified.toLocaleString() : null}\n note={\n funnel.qualifiedRate === null\n ? read\n ? 'nobody captured in the period'\n : undefined\n : `${percent(funnel.qualifiedRate)} of those captured`\n }\n />\n <ReportStatTile\n label={'Unqualified'}\n value={read ? funnel.byStatus.unqualified.toLocaleString() : null}\n note={\n funnel.unqualifiedRate === null\n ? undefined\n : `${percent(funnel.unqualifiedRate)} of those captured`\n }\n riseIsGood={false}\n />\n <ReportStatTile\n label={'Still open'}\n value={read ? funnel.open.toLocaleString() : null}\n note={'new or being worked'}\n href={routes.section('leads')}\n />\n </Stack>\n {captured.status === 'error' || status === 'error' ? (\n <Alert severity=\"warning\">{errorText}</Alert>\n ) : null}\n <Section title={'Where they stand'}>\n <ReportBreakdown\n rows={Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => ({\n key: leadStatus,\n label: Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n value: funnel.byStatus[leadStatus],\n note: share(funnel.byStatus[leadStatus], funnel.total) ?? undefined,\n color:\n leadStatus === 'qualified'\n ? 'success.main'\n : leadStatus === 'unqualified'\n ? 'warning.main'\n : undefined,\n }))}\n emptyText={''}\n />\n {read && !funnel.total ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No leads captured in this period.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Why leads were unqualified'}>\n <ReportBreakdown\n rows={reasons.map((reason) => ({\n key: reason.key,\n label: reason.label,\n value: reason.count,\n note: share(reason.count, funnel.byStatus.unqualified) ?? undefined,\n color: 'warning.main',\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No lead was unqualified in this period.'\n }\n />\n {moreReasons > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`And ${plural(moreReasons, 'other reason')}, each given once or twice.`}\n </Typography>\n ) : null}\n </Section>\n <ReportExport\n filename={reportFilename('lead-funnel', period)}\n columns={COLUMNS}\n rows={() => [\n ...Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => [\n 'Status',\n Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n funnel.byStatus[leadStatus],\n share(funnel.byStatus[leadStatus], funnel.total) ?? '',\n ]),\n ...funnel.reasons.map((reason) => [\n 'Unqualified reason',\n reason.label,\n reason.count,\n share(reason.count, funnel.byStatus.unqualified) ?? '',\n ]),\n ]}\n disabled={!read || !funnel.total}\n caption={caption}\n />\n </Stack>\n </CardDisplay>\n )\n}\nLeadFunnelBody.displayName = 'LeadFunnelBody'\nLeadFunnelCard.displayName = 'LeadFunnelCard'\n\nexport default LeadFunnelCard\n"],"names":["Aglyn","AppLink","CardDisplay","percent","Section","ceilingedWindow","Alert","Button","Stack","Typography","collection","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","useCrmOrgMount","ReportBreakdown","ReportExport","plural","reportFilename","reportCacheKey","ReportStatTile","useAggregateRead","useWindowRead","LEAD_CEILING","REASONS_SHOWN","COLUMNS","LEAD_FUNNEL_HELP","pluginDocsHelp","anchor","excerpt","leadsBetween","firestore","hostId","from","to","countOf","target","then","snapshot","data","count","LeadFunnelCard","props","report","mount","SiteLeadFunnelCard","hostsReady","hosts","length","OrgLeadFunnelCard","header","help","contentGutterX","contentGutterY","variant","color","range","captured","Promise","all","previousFrom","previousTo","current","previous","cacheKey","window","figures","value","sampled","status","truncated","rows","LeadFunnelBody","caption","toLocaleString","undefined","errorText","displayName","hostIds","map","host","id","key","join","pairs","reduce","sum","merged","docs","document","$id","windows","flatMap","sort","a","b","Number","some","sites","subheader","period","routes","funnel","leadFunnel","read","share","of","reasons","slice","moreReasons","HeaderProps","action","component","componentVariant","nativeButton","href","section","size","spacing","direction","sx","flexWrap","label","deltaPct","deltaPercent","note","byStatus","qualified","qualifiedRate","unqualified","unqualifiedRate","riseIsGood","open","severity","title","CRM_LEAD_STATUSES","leadStatus","CRM_LEAD_STATUS_LABELS","total","emptyText","reason","filename","columns","disabled"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-funnel-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n type Firestore,\n getCountFromServer,\n getDocs,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../../hooks/use-crm-org-mount'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename } from './report-format'\nimport { type CrmReportScope, reportCacheKey } from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport {\n type AggregateRead,\n useAggregateRead,\n useWindowRead,\n type WindowRead,\n} from './use-aggregate-read'\n\n/**\n * How many of the period's leads the funnel is placed from — the Leads\n * section's own window, over the period rather than over everything.\n *\n * The captured count is a server aggregate and never meets this bound; the\n * funnel needs each lead's status and reason, so it reads the period's\n * newest two hundred and says when there were more. At the organization\n * level the bound is PER SITE (AGL-2634): each site's window is its own,\n * and a site with a thousand leads must not crowd out a site with ten.\n */\nexport const LEAD_CEILING = 200\n/** How many reasons the bar list draws; the rest are counted beneath it. */\nconst REASONS_SHOWN = 8\n\n/** The CSV's columns: one file for both the statuses and the reasons. */\nconst COLUMNS = ['Kind', 'Label', 'Count', 'Share'] as const\n\ntype LeadRow = Record<string, unknown> & Aglyn.CrmLeadFields & { $id: string }\n\n/** The period's captured count, and the previous period's for the delta. */\ninterface CapturedFigures {\n current: number\n previous: number\n}\n\nexport interface LeadFunnelCardProps {\n report: CrmReportScope\n /**\n * The site whose leads are placed. A lead lives under its host, not the\n * org — `hosts/{hostId}/leads`, private by path, no `visibleTo` — so the\n * report's org scope cannot reach it and the card is told the site. At\n * the organization level `null` (AGL-2630): the card then reads every\n * site the mount lists, one window each (AGL-2634).\n */\n hostId: string | null\n}\n\nconst LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#lead-funnel',\n excerpt:\n 'Of the leads this site captured in the period, how many are ' +\n 'still new, being worked, qualified or unqualified — and the ' +\n 'reasons the unqualified ones were closed. Placed by when each ' +\n 'lead was first seen.',\n})\n\n/**\n * The leads a scope may see, first seen in a range (AGL-3275).\n *\n * `visibleTo` is the site's tokens under a site, and `null` at the\n * organization level where an org-wide member reads without a clause. The\n * range stays a single-field one; with the scope clause it composes with the\n * `visibleTo` + `firstSeenAtMs` entry in `cloud/firebase-firestore.indexes.json`.\n */\nfunction leadsBetween(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | null,\n from: number,\n to: number,\n) {\n return query(\n collection(firestore, 'orgs', orgId, 'leads'),\n ...(visibleTo ? [where('visibleTo', 'array-contains-any', [...visibleTo])] : []),\n where('firstSeenAtMs', '>=', from),\n where('firstSeenAtMs', '<', to),\n )\n}\n\nconst countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n/**\n * The lead funnel, at whichever level the report is mounted (AGL-2624,\n * AGL-2630, AGL-2634).\n *\n * Under a site: that site. At the organization level: every site the\n * mount lists, one window each, totaled — the org hub's reports total\n * every site, and the leads are no exception now that the fan-out is\n * bounded by the org's site list. With no sites to read the card says so\n * instead of guessing.\n */\nexport function LeadFunnelCard(props: LeadFunnelCardProps) {\n const { report, hostId } = props\n const mount = useCrmOrgMount()\n if (hostId) return <SiteLeadFunnelCard report={report} hostId={hostId} />\n if (mount && (!mount.hostsReady || mount.hosts.length > 0)) {\n return <OrgLeadFunnelCard report={report} />\n }\n return (\n <CardDisplay header={'Lead funnel'} help={LEAD_FUNNEL_HELP} contentGutterX contentGutterY>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {mount\n ? 'Leads live under a site, and this organization has no sites yet.'\n : 'Leads live under a site, and this report is mounted under none.'}\n </Typography>\n </CardDisplay>\n )\n}\n\n/**\n * The lead funnel (AGL-2624): of the leads this site captured in the\n * period, how many are still new, being worked, qualified or unqualified —\n * and why the unqualified ones were closed.\n *\n * A cohort by first sighting, followed to where each lead stands now,\n * because that is the question a period asks of leads — \"what became of\n * the ones we got\" — and because a lead's status has no history on the\n * document: only its current value. The window is a range on\n * `firstSeenAtMs`, which the capture door stamps once on creation and\n * never again, so a returning visitor's second form is not a second lead\n * in a second period. A single-field range needs no composite index.\n *\n * Read directly off `hosts/{hostId}/leads` under the site's own rules —\n * any member of the site may read it — the way the Leads section reads\n * it, so the funnel and the list can never disagree about who may see a\n * lead.\n */\nfunction SiteLeadFunnelCard(props: { report: CrmReportScope; hostId: string }) {\n const { report, hostId } = props\n const { range } = report\n const firestore = useFirestore()\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], report.tokens, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:counts:${hostId}`) },\n )\n const window = useWindowRead<LeadRow>(\n () =>\n query(\n leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n LEAD_CEILING,\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:window:${hostId}`) },\n )\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n (figures !== null ? ` of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'This site’s leads could not be read.'}\n />\n )\n}\nSiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard'\n\n/**\n * THE ORGANIZATION'S FUNNEL (AGL-2634, AGL-3275): every lead the org holds.\n *\n * This totaled one aggregate and one window PER SITE, because the sites'\n * lead collections were disjoint. They share one collection now, so a sum\n * would count a lead that two brands in a consent group both hold once for\n * each of them — and the merge that existed so a busy site could not crowd\n * out a quiet one has nothing left to balance. One aggregate and one\n * bounded read, unscoped, as an org-wide member's reads are.\n */\nfunction OrgLeadFunnelCard(props: { report: CrmReportScope }) {\n const { report } = props\n const { range } = report\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const hostIds = useMemo(\n () => (mount?.hostsReady ? mount.hosts.map((host) => host.id) : null),\n [mount],\n )\n const key = hostIds?.join('\\n') ?? ''\n\n /*\n * ONE READ, not one per site (AGL-3275). The sites share a collection now,\n * so a lead two brands in a consent group both hold would be counted once\n * for each of them — and the org's funnel would report more leads than the\n * org has. `null` tokens is the org-wide read that carries no clause.\n */\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], null, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], null, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:counts:org') },\n )\n const merged = useAggregateRead<{ rows: LeadRow[]; truncated: boolean }>(\n () =>\n getDocs(\n query(\n leadsBetween(firestore, report.scope[1], null, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n ).then((snapshot) =>\n ceilingedWindow(\n snapshot.docs.map(\n (document) => ({ ...document.data(), $id: document.id }) as unknown as LeadRow,\n ),\n LEAD_CEILING,\n ),\n ),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:window:org') },\n )\n const window = useMemo<WindowRead<LeadRow>>(\n () => ({\n rows: merged.value?.rows ?? [],\n truncated: merged.value?.truncated ?? false,\n status: merged.status,\n }),\n [merged],\n )\n const sites = hostIds?.length ?? 0\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n subheader={`Every site (${sites.toLocaleString()})`}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n ` — at most ${LEAD_CEILING.toLocaleString()}` +\n (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'The organization’s leads could not be read.'}\n />\n )\n}\nOrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard'\n\n/**\n * The funnel as drawn, whichever level fed it: four tiles, the statuses,\n * the reasons, the export. Pure over the two reads.\n */\nfunction LeadFunnelBody(props: {\n report: CrmReportScope\n subheader?: string\n captured: AggregateRead<CapturedFigures>\n window: WindowRead<LeadRow>\n caption?: string\n errorText: string\n}) {\n const { report, subheader, captured, window, caption, errorText } = props\n const { period, routes } = report\n const status = window.status\n\n const funnel = useMemo(() => Aglyn.leadFunnel(window.rows), [window])\n const read = status === 'success'\n const figures = captured.value\n const share = (count: number, of: number): string | null =>\n of > 0 ? percent(count / of) : null\n const reasons = funnel.reasons.slice(0, REASONS_SHOWN)\n const moreReasons = funnel.reasons.length - reasons.length\n\n return (\n <CardDisplay\n header={'Lead funnel'}\n subheader={subheader}\n help={LEAD_FUNNEL_HELP}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('leads')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open leads'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Leads captured'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? Aglyn.deltaPercent(figures.current, figures.previous) : null}\n note={'first seen in the period'}\n href={routes.section('leads')}\n />\n <ReportStatTile\n label={'Qualified'}\n value={read ? funnel.byStatus.qualified.toLocaleString() : null}\n note={\n funnel.qualifiedRate === null\n ? read\n ? 'nobody captured in the period'\n : undefined\n : `${percent(funnel.qualifiedRate)} of those captured`\n }\n />\n <ReportStatTile\n label={'Unqualified'}\n value={read ? funnel.byStatus.unqualified.toLocaleString() : null}\n note={\n funnel.unqualifiedRate === null\n ? undefined\n : `${percent(funnel.unqualifiedRate)} of those captured`\n }\n riseIsGood={false}\n />\n <ReportStatTile\n label={'Still open'}\n value={read ? funnel.open.toLocaleString() : null}\n note={'new or being worked'}\n href={routes.section('leads')}\n />\n </Stack>\n {captured.status === 'error' || status === 'error' ? (\n <Alert severity=\"warning\">{errorText}</Alert>\n ) : null}\n <Section title={'Where they stand'}>\n <ReportBreakdown\n rows={Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => ({\n key: leadStatus,\n label: Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n value: funnel.byStatus[leadStatus],\n note: share(funnel.byStatus[leadStatus], funnel.total) ?? undefined,\n color:\n leadStatus === 'qualified'\n ? 'success.main'\n : leadStatus === 'unqualified'\n ? 'warning.main'\n : undefined,\n }))}\n emptyText={''}\n />\n {read && !funnel.total ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No leads captured in this period.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Why leads were unqualified'}>\n <ReportBreakdown\n rows={reasons.map((reason) => ({\n key: reason.key,\n label: reason.label,\n value: reason.count,\n note: share(reason.count, funnel.byStatus.unqualified) ?? undefined,\n color: 'warning.main',\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No lead was unqualified in this period.'\n }\n />\n {moreReasons > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`And ${plural(moreReasons, 'other reason')}, each given once or twice.`}\n </Typography>\n ) : null}\n </Section>\n <ReportExport\n filename={reportFilename('lead-funnel', period)}\n columns={COLUMNS}\n rows={() => [\n ...Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => [\n 'Status',\n Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n funnel.byStatus[leadStatus],\n share(funnel.byStatus[leadStatus], funnel.total) ?? '',\n ]),\n ...funnel.reasons.map((reason) => [\n 'Unqualified reason',\n reason.label,\n reason.count,\n share(reason.count, funnel.byStatus.unqualified) ?? '',\n ]),\n ]}\n disabled={!read || !funnel.total}\n caption={caption}\n />\n </Stack>\n </CardDisplay>\n )\n}\nLeadFunnelBody.displayName = 'LeadFunnelBody'\nLeadFunnelCard.displayName = 'LeadFunnelCard'\n\nexport default LeadFunnelCard\n"],"names":["Aglyn","AppLink","CardDisplay","percent","Section","ceilingedWindow","Alert","Button","Stack","Typography","collection","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","useCrmOrgMount","ReportBreakdown","ReportExport","plural","reportFilename","reportCacheKey","ReportStatTile","useAggregateRead","useWindowRead","LEAD_CEILING","REASONS_SHOWN","COLUMNS","LEAD_FUNNEL_HELP","pluginDocsHelp","anchor","excerpt","leadsBetween","firestore","orgId","visibleTo","from","to","countOf","target","then","snapshot","data","count","LeadFunnelCard","props","report","hostId","mount","SiteLeadFunnelCard","hostsReady","hosts","length","OrgLeadFunnelCard","header","help","contentGutterX","contentGutterY","variant","color","range","captured","Promise","all","scope","tokens","previousFrom","previousTo","current","previous","cacheKey","window","figures","value","sampled","status","truncated","rows","LeadFunnelBody","caption","toLocaleString","undefined","errorText","displayName","hostIds","map","host","id","key","join","merged","docs","document","$id","sites","subheader","period","routes","funnel","leadFunnel","read","share","of","reasons","slice","moreReasons","HeaderProps","action","component","componentVariant","nativeButton","href","section","size","spacing","direction","sx","flexWrap","label","deltaPct","deltaPercent","note","byStatus","qualified","qualifiedRate","unqualified","unqualifiedRate","riseIsGood","open","severity","title","CRM_LEAD_STATUSES","leadStatus","CRM_LEAD_STATUS_LABELS","total","emptyText","reason","filename","columns","disabled"],"mappings":"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"}
|
|
@@ -1,53 +1,50 @@
|
|
|
1
1
|
import { type DocumentData } from 'firebase/firestore';
|
|
2
|
-
/**
|
|
2
|
+
/** A lead, as the organization-level list carries it. */
|
|
3
3
|
export interface OrgLeadRow extends DocumentData {
|
|
4
4
|
/**
|
|
5
|
-
* The grid's row id
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The grid's row id, which is the lead's own document id.
|
|
6
|
+
*
|
|
7
|
+
* It used to be `{hostId}/{leadId}`, because a lead lived under its site
|
|
8
|
+
* and the same person on two sites was two documents with the SAME id —
|
|
9
|
+
* so the id alone could not key a list spanning sites. AGL-3275 made that
|
|
10
|
+
* one document, and the compound key went with it.
|
|
8
11
|
*/
|
|
9
12
|
$id: string;
|
|
10
|
-
/** `
|
|
13
|
+
/** `orgs/{orgId}/leads/{leadId}` — the document id, a person key. */
|
|
11
14
|
leadId: string;
|
|
12
|
-
/** The site the lead lives under; what every write and link names. */
|
|
13
|
-
hostId: string;
|
|
14
15
|
}
|
|
15
16
|
export interface OrgLeadsResult {
|
|
16
|
-
/**
|
|
17
|
+
/** The window, ordered newest-seen first. */
|
|
17
18
|
data: OrgLeadRow[];
|
|
18
|
-
/** `loading` until
|
|
19
|
+
/** `loading` until the listener has answered once; `error` when it refused. */
|
|
19
20
|
status: 'loading' | 'success' | 'error';
|
|
20
|
-
/**
|
|
21
|
+
/** There are leads beyond the window this list is showing. */
|
|
21
22
|
truncated: boolean;
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
|
-
* THE ORGANIZATION'S LEADS,
|
|
25
|
+
* THE ORGANIZATION'S LEADS, in one listener (AGL-3275).
|
|
25
26
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* the document to group by, and no
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* org-wide member is a member of every site, which is what admits each
|
|
32
|
-
* listener under the rules the site section already relies on; an org has
|
|
33
|
-
* a handful of sites and at most thirty in a consent group, so the fan-out
|
|
34
|
-
* is bounded by the org, not by the data.
|
|
27
|
+
* This opened one listener PER SITE and merged the answers, because a lead
|
|
28
|
+
* lived at `hosts/{hostId}/leads` — host-scoped by path, with no org-level
|
|
29
|
+
* collection to listen to, no `orgId` on the document to group by, and no
|
|
30
|
+
* rule admitting a collection-group read. All three of those are now false:
|
|
31
|
+
* the collection is `orgs/{orgId}/leads` and a lead says who may see it.
|
|
35
32
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* more, so the caption beneath the table can say the window is not the
|
|
41
|
-
* whole collection.
|
|
33
|
+
* So the fan-out is gone, and with it the merge that had to cut each site's
|
|
34
|
+
* window at `windowSize + 1` and the whole list again, so that a site with a
|
|
35
|
+
* thousand leads could not crowd out a site with ten. One ordered query needs
|
|
36
|
+
* neither: `truncated` is simply "the window came back full".
|
|
42
37
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
38
|
+
* `visibleTo` is the caller's, and `null` means "ask for everything" — which
|
|
39
|
+
* is what an org-wide member's listeners do, since the rules short-circuit on
|
|
40
|
+
* `isOrgWideMember()` and a clause would only narrow what they may already
|
|
41
|
+
* read. A scoped member passes their tokens. See `crmVisibleToClause`, which
|
|
42
|
+
* builds the same clause for every other CRM listener.
|
|
47
43
|
*/
|
|
48
44
|
export declare function useOrgLeads(options: {
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
orgId: string | null | undefined;
|
|
46
|
+
/** The scope clause, or `null` for an unscoped org-wide read. */
|
|
47
|
+
visibleTo: readonly string[] | null;
|
|
51
48
|
windowSize: number;
|
|
52
49
|
}): OrgLeadsResult;
|
|
53
50
|
export default useOrgLeads;
|
|
@@ -16,97 +16,87 @@
|
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { useFirestore } from "@aglyn/tenant-feature-instance";
|
|
19
|
-
import { collection, limit, onSnapshot, orderBy, query } from "firebase/firestore";
|
|
19
|
+
import { collection, limit, onSnapshot, orderBy, query, where } from "firebase/firestore";
|
|
20
20
|
import { useEffect, useMemo, useState } from "react";
|
|
21
21
|
/**
|
|
22
|
-
* THE ORGANIZATION'S LEADS,
|
|
22
|
+
* THE ORGANIZATION'S LEADS, in one listener (AGL-3275).
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the document to group by, and no
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* org-wide member is a member of every site, which is what admits each
|
|
30
|
-
* listener under the rules the site section already relies on; an org has
|
|
31
|
-
* a handful of sites and at most thirty in a consent group, so the fan-out
|
|
32
|
-
* is bounded by the org, not by the data.
|
|
24
|
+
* This opened one listener PER SITE and merged the answers, because a lead
|
|
25
|
+
* lived at `hosts/{hostId}/leads` — host-scoped by path, with no org-level
|
|
26
|
+
* collection to listen to, no `orgId` on the document to group by, and no
|
|
27
|
+
* rule admitting a collection-group read. All three of those are now false:
|
|
28
|
+
* the collection is `orgs/{orgId}/leads` and a lead says who may see it.
|
|
33
29
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* more, so the caption beneath the table can say the window is not the
|
|
39
|
-
* whole collection.
|
|
30
|
+
* So the fan-out is gone, and with it the merge that had to cut each site's
|
|
31
|
+
* window at `windowSize + 1` and the whole list again, so that a site with a
|
|
32
|
+
* thousand leads could not crowd out a site with ten. One ordered query needs
|
|
33
|
+
* neither: `truncated` is simply "the window came back full".
|
|
40
34
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
35
|
+
* `visibleTo` is the caller's, and `null` means "ask for everything" — which
|
|
36
|
+
* is what an org-wide member's listeners do, since the rules short-circuit on
|
|
37
|
+
* `isOrgWideMember()` and a clause would only narrow what they may already
|
|
38
|
+
* read. A scoped member passes their tokens. See `crmVisibleToClause`, which
|
|
39
|
+
* builds the same clause for every other CRM listener.
|
|
45
40
|
*/ export function useOrgLeads(options) {
|
|
46
|
-
const {
|
|
41
|
+
const { orgId, visibleTo, windowSize } = options;
|
|
47
42
|
const firestore = useFirestore();
|
|
48
|
-
const
|
|
49
|
-
const [
|
|
43
|
+
const scopeKey = visibleTo ? visibleTo.join('\n') : null;
|
|
44
|
+
const [answer, setAnswer] = useState(null);
|
|
50
45
|
useEffect(()=>{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
46
|
+
setAnswer(null);
|
|
47
|
+
if (!orgId) return undefined;
|
|
48
|
+
// An `array-contains-any` over nothing is a query Firestore refuses, so a
|
|
49
|
+
// scoped member with no tokens lists nothing rather than asking.
|
|
50
|
+
const tokens = scopeKey === null ? null : scopeKey ? scopeKey.split('\n') : [];
|
|
51
|
+
if (tokens && !tokens.length) {
|
|
52
|
+
setAnswer({
|
|
53
|
+
rows: [],
|
|
54
|
+
truncated: false
|
|
55
|
+
});
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const clauses = tokens ? [
|
|
59
|
+
where('visibleTo', 'array-contains-any', tokens)
|
|
60
|
+
] : [];
|
|
61
|
+
return onSnapshot(query(collection(firestore, 'orgs', orgId, 'leads'), ...clauses, orderBy('lastSeenAtMs', 'desc'), limit(windowSize + 1)), (snapshot)=>{
|
|
62
|
+
const rows = snapshot.docs.map((entry)=>_extends({}, entry.data(), {
|
|
63
|
+
$id: entry.id,
|
|
64
|
+
leadId: entry.id
|
|
65
|
+
}));
|
|
66
|
+
setAnswer({
|
|
67
|
+
rows: rows.slice(0, windowSize),
|
|
68
|
+
truncated: rows.length > windowSize
|
|
69
|
+
});
|
|
70
|
+
}, (error)=>{
|
|
71
|
+
console.error(error);
|
|
72
|
+
setAnswer('error');
|
|
73
|
+
});
|
|
75
74
|
}, [
|
|
76
75
|
firestore,
|
|
77
|
-
|
|
76
|
+
orgId,
|
|
77
|
+
scopeKey,
|
|
78
78
|
windowSize
|
|
79
79
|
]);
|
|
80
80
|
return useMemo(()=>{
|
|
81
|
-
|
|
82
|
-
const answers = sites.map((hostId)=>bySite[hostId]);
|
|
83
|
-
if (answers.some((answer)=>answer === 'error')) {
|
|
81
|
+
if (answer === 'error') {
|
|
84
82
|
return {
|
|
85
83
|
data: [],
|
|
86
84
|
status: 'error',
|
|
87
85
|
truncated: false
|
|
88
86
|
};
|
|
89
87
|
}
|
|
90
|
-
if (!
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
const merged = answers.flatMap((answer)=>answer === 'error' || !answer ? [] : answer.rows).sort((a, b)=>{
|
|
98
|
-
var _b_lastSeenAtMs, _a_lastSeenAtMs;
|
|
99
|
-
return Number((_b_lastSeenAtMs = b['lastSeenAtMs']) != null ? _b_lastSeenAtMs : 0) - Number((_a_lastSeenAtMs = a['lastSeenAtMs']) != null ? _a_lastSeenAtMs : 0);
|
|
100
|
-
});
|
|
88
|
+
if (!answer) return {
|
|
89
|
+
data: [],
|
|
90
|
+
status: 'loading',
|
|
91
|
+
truncated: false
|
|
92
|
+
};
|
|
101
93
|
return {
|
|
102
|
-
data:
|
|
94
|
+
data: answer.rows,
|
|
103
95
|
status: 'success',
|
|
104
|
-
truncated:
|
|
96
|
+
truncated: answer.truncated
|
|
105
97
|
};
|
|
106
98
|
}, [
|
|
107
|
-
|
|
108
|
-
key,
|
|
109
|
-
windowSize
|
|
99
|
+
answer
|
|
110
100
|
]);
|
|
111
101
|
}
|
|
112
102
|
export default useOrgLeads;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-org-leads.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'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n onSnapshot,\n orderBy,\n query,\n type DocumentData,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\n\n/**
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-org-leads.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'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n type DocumentData,\n type QueryConstraint,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\n\n/** A lead, as the organization-level list carries it. */\nexport interface OrgLeadRow extends DocumentData {\n /**\n * The grid's row id, which is the lead's own document id.\n *\n * It used to be `{hostId}/{leadId}`, because a lead lived under its site\n * and the same person on two sites was two documents with the SAME id —\n * so the id alone could not key a list spanning sites. AGL-3275 made that\n * one document, and the compound key went with it.\n */\n $id: string\n /** `orgs/{orgId}/leads/{leadId}` — the document id, a person key. */\n leadId: string\n}\n\nexport interface OrgLeadsResult {\n /** The window, ordered newest-seen first. */\n data: OrgLeadRow[]\n /** `loading` until the listener has answered once; `error` when it refused. */\n status: 'loading' | 'success' | 'error'\n /** There are leads beyond the window this list is showing. */\n truncated: boolean\n}\n\n/**\n * THE ORGANIZATION'S LEADS, in one listener (AGL-3275).\n *\n * This opened one listener PER SITE and merged the answers, because a lead\n * lived at `hosts/{hostId}/leads` — host-scoped by path, with no org-level\n * collection to listen to, no `orgId` on the document to group by, and no\n * rule admitting a collection-group read. All three of those are now false:\n * the collection is `orgs/{orgId}/leads` and a lead says who may see it.\n *\n * So the fan-out is gone, and with it the merge that had to cut each site's\n * window at `windowSize + 1` and the whole list again, so that a site with a\n * thousand leads could not crowd out a site with ten. One ordered query needs\n * neither: `truncated` is simply \"the window came back full\".\n *\n * `visibleTo` is the caller's, and `null` means \"ask for everything\" — which\n * is what an org-wide member's listeners do, since the rules short-circuit on\n * `isOrgWideMember()` and a clause would only narrow what they may already\n * read. A scoped member passes their tokens. See `crmVisibleToClause`, which\n * builds the same clause for every other CRM listener.\n */\nexport function useOrgLeads(options: {\n orgId: string | null | undefined\n /** The scope clause, or `null` for an unscoped org-wide read. */\n visibleTo: readonly string[] | null\n windowSize: number\n}): OrgLeadsResult {\n const { orgId, visibleTo, windowSize } = options\n const firestore = useFirestore()\n const scopeKey = visibleTo ? visibleTo.join('\\n') : null\n const [answer, setAnswer] = useState<\n { rows: OrgLeadRow[]; truncated: boolean } | 'error' | null\n >(null)\n\n useEffect(() => {\n setAnswer(null)\n if (!orgId) return undefined\n // An `array-contains-any` over nothing is a query Firestore refuses, so a\n // scoped member with no tokens lists nothing rather than asking.\n const tokens = scopeKey === null ? null : scopeKey ? scopeKey.split('\\n') : []\n if (tokens && !tokens.length) {\n setAnswer({ rows: [], truncated: false })\n return undefined\n }\n const clauses: QueryConstraint[] = tokens\n ? [where('visibleTo', 'array-contains-any', tokens)]\n : []\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, 'leads'),\n ...clauses,\n orderBy('lastSeenAtMs', 'desc'),\n limit(windowSize + 1),\n ),\n (snapshot) => {\n const rows = snapshot.docs.map((entry) => ({\n ...(entry.data() as DocumentData),\n $id: entry.id,\n leadId: entry.id,\n }))\n setAnswer({\n rows: rows.slice(0, windowSize),\n truncated: rows.length > windowSize,\n })\n },\n (error) => {\n console.error(error)\n setAnswer('error')\n },\n )\n }, [firestore, orgId, scopeKey, windowSize])\n\n return useMemo(() => {\n if (answer === 'error') {\n return { data: [], status: 'error' as const, truncated: false }\n }\n if (!answer) return { data: [], status: 'loading' as const, truncated: false }\n return {\n data: answer.rows,\n status: 'success' as const,\n truncated: answer.truncated,\n }\n }, [answer])\n}\n\nexport default useOrgLeads\n"],"names":["useFirestore","collection","limit","onSnapshot","orderBy","query","where","useEffect","useMemo","useState","useOrgLeads","options","orgId","visibleTo","windowSize","firestore","scopeKey","join","answer","setAnswer","undefined","tokens","split","length","rows","truncated","clauses","snapshot","docs","map","entry","data","$id","id","leadId","slice","error","console","status"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,UAAU,EACVC,KAAK,EACLC,UAAU,EACVC,OAAO,EACPC,KAAK,EACLC,KAAK,QAGA,qBAAoB;AAC3B,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AA0BpD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,YAAYC,OAK3B;IACC,MAAM,EAAEC,KAAK,EAAEC,SAAS,EAAEC,UAAU,EAAE,GAAGH;IACzC,MAAMI,YAAYf;IAClB,MAAMgB,WAAWH,YAAYA,UAAUI,IAAI,CAAC,QAAQ;IACpD,MAAM,CAACC,QAAQC,UAAU,GAAGV,SAE1B;IAEFF,UAAU;QACRY,UAAU;QACV,IAAI,CAACP,OAAO,OAAOQ;QACnB,0EAA0E;QAC1E,iEAAiE;QACjE,MAAMC,SAASL,aAAa,OAAO,OAAOA,WAAWA,SAASM,KAAK,CAAC,QAAQ,EAAE;QAC9E,IAAID,UAAU,CAACA,OAAOE,MAAM,EAAE;YAC5BJ,UAAU;gBAAEK,MAAM,EAAE;gBAAEC,WAAW;YAAM;YACvC,OAAOL;QACT;QACA,MAAMM,UAA6BL,SAC/B;YAACf,MAAM,aAAa,sBAAsBe;SAAQ,GAClD,EAAE;QACN,OAAOlB,WACLE,MACEJ,WAAWc,WAAW,QAAQH,OAAO,aAClCc,SACHtB,QAAQ,gBAAgB,SACxBF,MAAMY,aAAa,KAErB,CAACa;YACC,MAAMH,OAAOG,SAASC,IAAI,CAACC,GAAG,CAAC,CAACC,QAAW,aACrCA,MAAMC,IAAI;oBACdC,KAAKF,MAAMG,EAAE;oBACbC,QAAQJ,MAAMG,EAAE;;YAElBd,UAAU;gBACRK,MAAMA,KAAKW,KAAK,CAAC,GAAGrB;gBACpBW,WAAWD,KAAKD,MAAM,GAAGT;YAC3B;QACF,GACA,CAACsB;YACCC,QAAQD,KAAK,CAACA;YACdjB,UAAU;QACZ;IAEJ,GAAG;QAACJ;QAAWH;QAAOI;QAAUF;KAAW;IAE3C,OAAON,QAAQ;QACb,IAAIU,WAAW,SAAS;YACtB,OAAO;gBAAEa,MAAM,EAAE;gBAAEO,QAAQ;gBAAkBb,WAAW;YAAM;QAChE;QACA,IAAI,CAACP,QAAQ,OAAO;YAAEa,MAAM,EAAE;YAAEO,QAAQ;YAAoBb,WAAW;QAAM;QAC7E,OAAO;YACLM,MAAMb,OAAOM,IAAI;YACjBc,QAAQ;YACRb,WAAWP,OAAOO,SAAS;QAC7B;IACF,GAAG;QAACP;KAAO;AACb;AAEA,eAAeR,YAAW"}
|
|
@@ -54,13 +54,16 @@ export declare function crmRoutes(basePath: string): {
|
|
|
54
54
|
*/
|
|
55
55
|
contactByEmail: (email: string) => string;
|
|
56
56
|
/**
|
|
57
|
-
* A lead's page
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
57
|
+
* A lead's page: the id alone, at both levels (AGL-3275).
|
|
58
|
+
*
|
|
59
|
+
* This carried the SITE as a segment before the id at the organization
|
|
60
|
+
* level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`
|
|
61
|
+
* was host-scoped by path, and so an address that two sites had both met
|
|
62
|
+
* was two documents with the same id — which the id alone could not tell
|
|
63
|
+
* apart. One org collection makes the person key unambiguous again, and a
|
|
64
|
+
* lead addresses like every other CRM record.
|
|
62
65
|
*/
|
|
63
|
-
lead: (id: string
|
|
66
|
+
lead: (id: string) => string;
|
|
64
67
|
company: (id: string) => string;
|
|
65
68
|
deal: (id: string) => string;
|
|
66
69
|
/**
|
|
@@ -57,12 +57,15 @@ import { CRM_VIEW_PARAM } from "./crm-view-param.js";
|
|
|
57
57
|
[CONTACTS_LIST_EMAIL_PARAM]: email
|
|
58
58
|
}).toString()}`,
|
|
59
59
|
/**
|
|
60
|
-
* A lead's page
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
60
|
+
* A lead's page: the id alone, at both levels (AGL-3275).
|
|
61
|
+
*
|
|
62
|
+
* This carried the SITE as a segment before the id at the organization
|
|
63
|
+
* level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`
|
|
64
|
+
* was host-scoped by path, and so an address that two sites had both met
|
|
65
|
+
* was two documents with the same id — which the id alone could not tell
|
|
66
|
+
* apart. One org collection makes the person key unambiguous again, and a
|
|
67
|
+
* lead addresses like every other CRM record.
|
|
68
|
+
*/ lead: (id)=>`${section('leads')}/${encodeURIComponent(id)}`,
|
|
66
69
|
company: (id)=>`${section('companies')}/${encodeURIComponent(id)}`,
|
|
67
70
|
deal: (id)=>`${section('deals')}/${encodeURIComponent(id)}`,
|
|
68
71
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CRM_CONTACTS_EMAIL_PARAM, type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport type { CrmConsoleSectionId } from '../components/crm-console-sections'\nimport { CRM_VIEW_PARAM } from './crm-view-param'\n\n/**\n * Every address inside the Contacts surface, built from the one string the\n * shell hands the page (AGL-2595).\n *\n * The hub owns three kinds of deeper route — `people/{contactId}`,\n * `companies/{companyId}`, `deals/{dealId}` — and eight surfaces link into\n * them: a deal names its contact and company, a task names all three, a\n * report drills into a deal, an activity names whoever it happened with.\n * Eight surfaces each spelling `${basePath}/people/${id}` is eight places\n * for one of them to spell it `contacts/`, and a link that 404s inside the\n * hub is indistinguishable from a record that was deleted.\n *\n * The id is URL-encoded because Firestore ids are opaque: the console mints\n * them, but an import or an API caller may not, and a slash in an id would\n * otherwise read as a further segment.\n */\nexport function crmRoutes(basePath: string) {\n const section = (id: CrmConsoleSectionId) => `${basePath}/${id}`\n return {\n section,\n contact: (id: string) => `${section('contacts')}/${encodeURIComponent(id)}`,\n /**\n * The Contacts list narrowed to the people one form captured (AGL-2612):\n * source `form`, and the `formIds` filter on the form's id. The form's\n * own page links here; the list reads the two keys back through\n * `contactsListSeed`, which is the other half of this address.\n */\n contactsByForm: (formId: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_SOURCE_PARAM]: 'form',\n [CONTACTS_LIST_FORM_PARAM]: formId,\n }).toString()}`,\n /**\n * The Contacts list asked to OPEN the one person with this address\n * (AGL-2612). The list is the lookup: a contact's id is minted at\n * capture and nothing outside the CRM holds it, so a surface that has\n * only an email — an Inbox submission row — links here, the list\n * filters on the address (a whole-collection query, under the scope\n * the viewer may read) and moves straight on to the record when exactly\n * one matches. No match leaves the filtered list on screen, which is\n * the honest answer for a submission whose contact the band dropped.\n */\n contactByEmail: (email: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_EMAIL_PARAM]: email,\n }).toString()}`,\n /**\n * A lead's page
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CRM_CONTACTS_EMAIL_PARAM, type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport type { CrmConsoleSectionId } from '../components/crm-console-sections'\nimport { CRM_VIEW_PARAM } from './crm-view-param'\n\n/**\n * Every address inside the Contacts surface, built from the one string the\n * shell hands the page (AGL-2595).\n *\n * The hub owns three kinds of deeper route — `people/{contactId}`,\n * `companies/{companyId}`, `deals/{dealId}` — and eight surfaces link into\n * them: a deal names its contact and company, a task names all three, a\n * report drills into a deal, an activity names whoever it happened with.\n * Eight surfaces each spelling `${basePath}/people/${id}` is eight places\n * for one of them to spell it `contacts/`, and a link that 404s inside the\n * hub is indistinguishable from a record that was deleted.\n *\n * The id is URL-encoded because Firestore ids are opaque: the console mints\n * them, but an import or an API caller may not, and a slash in an id would\n * otherwise read as a further segment.\n */\nexport function crmRoutes(basePath: string) {\n const section = (id: CrmConsoleSectionId) => `${basePath}/${id}`\n return {\n section,\n contact: (id: string) => `${section('contacts')}/${encodeURIComponent(id)}`,\n /**\n * The Contacts list narrowed to the people one form captured (AGL-2612):\n * source `form`, and the `formIds` filter on the form's id. The form's\n * own page links here; the list reads the two keys back through\n * `contactsListSeed`, which is the other half of this address.\n */\n contactsByForm: (formId: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_SOURCE_PARAM]: 'form',\n [CONTACTS_LIST_FORM_PARAM]: formId,\n }).toString()}`,\n /**\n * The Contacts list asked to OPEN the one person with this address\n * (AGL-2612). The list is the lookup: a contact's id is minted at\n * capture and nothing outside the CRM holds it, so a surface that has\n * only an email — an Inbox submission row — links here, the list\n * filters on the address (a whole-collection query, under the scope\n * the viewer may read) and moves straight on to the record when exactly\n * one matches. No match leaves the filtered list on screen, which is\n * the honest answer for a submission whose contact the band dropped.\n */\n contactByEmail: (email: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_EMAIL_PARAM]: email,\n }).toString()}`,\n /**\n * A lead's page: the id alone, at both levels (AGL-3275).\n *\n * This carried the SITE as a segment before the id at the organization\n * level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`\n * was host-scoped by path, and so an address that two sites had both met\n * was two documents with the same id — which the id alone could not tell\n * apart. One org collection makes the person key unambiguous again, and a\n * lead addresses like every other CRM record.\n */\n lead: (id: string) => `${section('leads')}/${encodeURIComponent(id)}`,\n company: (id: string) => `${section('companies')}/${encodeURIComponent(id)}`,\n deal: (id: string) => `${section('deals')}/${encodeURIComponent(id)}`,\n /**\n * A section opened on one of its saved views (AGL-2617). The list reads\n * the key back through `crmViewIdFromParams`; the same key composes\n * with the Contacts seeds above, which is why it is a query key and not\n * a path segment.\n */\n sectionView: (id: CrmConsoleSectionId, viewId: string) =>\n `${section(id)}?${new URLSearchParams({\n [CRM_VIEW_PARAM]: viewId,\n }).toString()}`,\n }\n}\n\n/**\n * The query keys the Contacts list reads on arrival — written by the two\n * builders above and parsed by `contactsListSeed`, named once so neither\n * side can misspell the other.\n */\nexport const CONTACTS_LIST_SOURCE_PARAM = 'source'\nexport const CONTACTS_LIST_FORM_PARAM = 'formId'\n/**\n * The email key is the SHARED constant (AGL-2622): the console app — which\n * the module boundaries keep from importing this plugin — builds the same\n * address through `crmContactByEmailHref`, and `crm-routes.spec.ts` pins\n * `contactByEmail` against it so the plugin and the app cannot spell the\n * key two ways.\n */\nexport const CONTACTS_LIST_EMAIL_PARAM = CRM_CONTACTS_EMAIL_PARAM\n\nexport type CrmRoutes = ReturnType<typeof crmRoutes>\n\n/**\n * What the hub hands a record page: the shell's context, the record's id and\n * the surface's own path so the page can link back to its list.\n *\n * `basePath` is required here where the shell's prop is optional, because a\n * record page is only ever reached THROUGH the hub, which has already refused\n * to render without one.\n */\nexport type CrmDetailPageProps = Pick<\n ConsolePluginPageProps,\n 'hostId' | 'org' | 'permissions' | 'releaseFlag' | 'hostRole'\n> & {\n id: string\n basePath: string\n}\n"],"names":["CRM_CONTACTS_EMAIL_PARAM","CRM_VIEW_PARAM","crmRoutes","basePath","section","id","contact","encodeURIComponent","contactsByForm","formId","URLSearchParams","CONTACTS_LIST_SOURCE_PARAM","CONTACTS_LIST_FORM_PARAM","toString","contactByEmail","email","CONTACTS_LIST_EMAIL_PARAM","lead","company","deal","sectionView","viewId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAqC,eAAc;AAEpF,SAASC,cAAc,QAAQ,sBAAkB;AAEjD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,UAAUC,QAAgB;IACxC,MAAMC,UAAU,CAACC,KAA4B,GAAGF,SAAS,CAAC,EAAEE,IAAI;IAChE,OAAO;QACLD;QACAE,SAAS,CAACD,KAAe,GAAGD,QAAQ,YAAY,CAAC,EAAEG,mBAAmBF,KAAK;QAC3E;;;;;KAKC,GACDG,gBAAgB,CAACC,SACf,GAAGL,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACC,2BAA2B,EAAE;gBAC9B,CAACC,yBAAyB,EAAEH;YAC9B,GAAGI,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDC,gBAAgB,CAACC,QACf,GAAGX,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACM,0BAA0B,EAAED;YAC/B,GAAGF,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDI,MAAM,CAACZ,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrEa,SAAS,CAACb,KAAe,GAAGD,QAAQ,aAAa,CAAC,EAAEG,mBAAmBF,KAAK;QAC5Ec,MAAM,CAACd,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrE;;;;;KAKC,GACDe,aAAa,CAACf,IAAyBgB,SACrC,GAAGjB,QAAQC,IAAI,CAAC,EAAE,IAAIK,gBAAgB;gBACpC,CAACT,eAAe,EAAEoB;YACpB,GAAGR,QAAQ,IAAI;IACnB;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAMF,6BAA6B,SAAQ;AAClD,OAAO,MAAMC,2BAA2B,SAAQ;AAChD;;;;;;CAMC,GACD,OAAO,MAAMI,4BAA4BhB,yBAAwB"}
|
|
@@ -18,7 +18,7 @@ import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
|
|
|
18
18
|
import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
|
|
19
19
|
import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
|
|
20
20
|
import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
|
|
21
|
-
import { addHostLead, findContactByEmail, firebaseAdmin, orgDataCollectionForHost } from "@aglyn/tenant-data-admin";
|
|
21
|
+
import { addHostLead, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
|
|
22
22
|
import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
23
23
|
/**
|
|
24
24
|
* The CRM answering the platform's contact-capture contract (AGL-3080).
|
|
@@ -119,8 +119,13 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
|
119
119
|
/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */ async function openLeadFor(request) {
|
|
120
120
|
var _snapshot_data;
|
|
121
121
|
const key = personKey(request.identity.email);
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
/*
|
|
123
|
+
* Through the seam, which reads the org row and falls back to a lead the
|
|
124
|
+
* backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand
|
|
125
|
+
* in the same consent group has to find this person, or its door concludes
|
|
126
|
+
* there is no lead and files the duplicate this migration exists to end.
|
|
127
|
+
*/ const snapshot = await readLeadForHost(request.hostId, key);
|
|
128
|
+
if (!snapshot) return false;
|
|
124
129
|
const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
125
130
|
return !lead.convertedContactId && isCrmLeadOpen(lead);
|
|
126
131
|
}
|
|
@@ -133,8 +138,9 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
|
133
138
|
const email = normalizeContactEmail(request.identity.email);
|
|
134
139
|
const key = personKey(email);
|
|
135
140
|
const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId);
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
// `addHostLead` resolves and writes the org collection itself (AGL-3275);
|
|
142
|
+
// this only needs to know whether the person is new, which decides the event.
|
|
143
|
+
const created = !await readLeadForHost(request.hostId, key);
|
|
138
144
|
const { formId } = entryPointOf(request.detail);
|
|
139
145
|
const source = request.interaction.source === 'form' && formId ? `form:${formId}` : request.interaction.source;
|
|
140
146
|
const stored = await addHostLead(_extends({
|