@aglyn/plugins-crm 1.0.0-beta.146 → 1.0.0-beta.149

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-crm",
3
- "version": "1.0.0-beta.146",
3
+ "version": "1.0.0-beta.149",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,18 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.146",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.146",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.146",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.146",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.146",
33
- "@aglyn/shared-ui-next": "1.0.0-beta.146",
34
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.146",
35
- "@aglyn/shared-util-email": "1.0.0-beta.146",
36
- "@aglyn/shared-util-http": "1.0.0-beta.146",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.146",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.146",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.146",
28
+ "@aglyn/aglyn": "1.0.0-beta.149",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.149",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.149",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.149",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.149",
33
+ "@aglyn/shared-ui-next": "1.0.0-beta.149",
34
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.149",
35
+ "@aglyn/shared-util-email": "1.0.0-beta.149",
36
+ "@aglyn/shared-util-http": "1.0.0-beta.149",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.149",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.149",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.149",
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/utilities": "^3.2.2",
42
42
  "@swc/helpers": "0.5.23",
@@ -17,7 +17,7 @@
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
- import { money } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
20
+ import { money } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
21
21
  import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
22
22
  import { Button, Stack, Typography } from "@mui/material";
23
23
  import { getAggregateFromServer, getCountFromServer, query, sum, Timestamp, where } from "firebase/firestore";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-glance-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 { money } from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Button, Stack, Typography } from '@mui/material'\nimport {\n getAggregateFromServer,\n getCountFromServer,\n query,\n sum,\n Timestamp,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useOrgDataScope,\n useOrgPlan,\n} from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { crmRoutes } from '../model/crm-routes'\nimport { openLeadsAcrossSites } from './reports/lead-counts'\nimport { scopedCollection, visibleToClause } from './reports/report-scope'\nimport { ReportStatTile } from './reports/report-stat-tile'\nimport { useAggregateRead } from './reports/use-aggregate-read'\n\nconst WEEK_MS = 7 * 24 * 60 * 60 * 1000\n\ninterface GlanceFigures {\n contacts: number\n newThisWeek: number\n pipelineCents: number\n tasksDue: number\n /** Open leads — new or being worked — on this site, or across the org's sites. */\n leadsToWork: number\n}\n\nexport interface CrmGlanceCardProps {\n /**\n * The site the card is on the dashboard of, or `null` beneath the org\n * hub's mount (AGL-2634), where the figures total every site: the\n * org-wide reads carry no scope clause, and the lead figure fans out\n * over the mount's site list.\n */\n hostId: string | null\n /** The org hub's own path, for the links when there is no site to derive them from. */\n basePath?: string\n}\n\n/**\n * CRM at a glance (AGL-2604): a `hostDashboard` widget with five numbers —\n * contacts, new this week, open pipeline value, tasks due today or overdue,\n * and leads still to work (AGL-2624) — each a link into the section that\n * explains it.\n *\n * Server aggregates and nothing else: a dashboard card mounts on every\n * visit to the site's front page, so it is the one surface on which a\n * bounded read of a thousand rows would be a cost paid for nothing. Each\n * number is a count or a sum the server takes over the same `visibleTo`\n * predicate the hub's sections query with, so the card never shows a figure\n * the section behind the link would not.\n *\n * The lead figure is two counts and a subtraction — every lead on the site,\n * less the closed ones — because a lead nobody has touched carries no status\n * field and Firestore cannot select on a field's absence; see\n * `openLeadsFromCounts`. Leads are host-scoped by path rather than by\n * `visibleTo`, so those two counts run on `hosts/{hostId}/leads` under the\n * site's own rules, the way the Leads section reads it.\n *\n * The widget is handed a host doc id and nothing else, so the org — needed\n * for the consent group whose tokens scope the reads — comes from\n * `useOrgPlan`, and the link base from `useConsoleHostRoute`, the way the\n * commerce glance card builds its store link. Nothing is read until the org\n * has settled: a first round of aggregates against the solo group, replaced\n * by a second against the real one, would be two reads for one answer.\n *\n * ## The organization level (AGL-2634)\n *\n * Mounted with no site beneath the org hub's mount, the card totals the\n * organization: the reads carry no scope clause — the rules admit an\n * org-wide member to every row — and the lead figure is the same two\n * counts per site, over every site the mount lists, summed. Nothing is\n * read until that list has settled, for the reason nothing is read under\n * a site until the org has. The org's dashboard has no widget slot yet, so\n * the mount is a capability rather than a page; what it needs from the\n * shell is a slot and the hub's `basePath`.\n */\nexport function CrmGlanceCard(props: CrmGlanceCardProps) {\n const { hostId, basePath } = props\n const mount = useCrmOrgMount()\n const consoleRoute = useConsoleHostRoute(hostId)\n const { org, ready: planReady } = useOrgPlan(hostId ?? undefined)\n const { scope } = useOrgDataScope(hostId ? { hostId } : { orgId: mount?.orgId })\n const firestore = useFirestore()\n // Under a site, the site's group's tokens once the org has settled; at\n // the org level `null` — no clause — once the site list has.\n const tokens = useMemo(() => {\n if (!hostId) return null\n if (!planReady) return undefined\n const group = Aglyn.consentGroupForHost(org ?? null, hostId)\n return [\n Aglyn.ORG_SCOPE_TOKEN,\n ...group.hostIds.map((id) => Aglyn.hostScopeToken(id)),\n ].slice(0, Aglyn.MAX_SCOPE_HOSTS)\n }, [org, planReady, hostId])\n const leadSites = useMemo(\n () =>\n hostId\n ? [hostId]\n : mount?.hostsReady\n ? mount.hosts.map((host) => host.id)\n : null,\n [hostId, mount],\n )\n const leadSitesKey = leadSites?.join('\\n') ?? ''\n // Anchored once per mount so the queries are stable across renders.\n const nowMs = useMemo(() => Date.now(), [])\n\n const figures = useAggregateRead<GlanceFigures>(() => {\n if (!scope || tokens === undefined || !leadSites) return null\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const deals = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.deals)\n const tasks = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks)\n const day = Aglyn.localDayBounds(nowMs)\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n return Promise.all([\n countOf(query(contacts, ...visibleToClause(tokens))),\n countOf(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(nowMs - WEEK_MS)),\n ),\n ),\n getAggregateFromServer(\n query(deals, ...visibleToClause(tokens), where('status', '==', 'open')),\n { amountCents: sum('amountCents') },\n ).then((snapshot) => Number(snapshot.data().amountCents ?? 0)),\n // Due today OR overdue: everything open that is due before the day ends.\n countOf(\n query(\n tasks,\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n where('dueAtMs', '<', day.end),\n ),\n ),\n openLeadsAcrossSites(firestore, leadSites),\n ]).then(([contacts, newThisWeek, pipelineCents, tasksDue, leadsToWork]) => ({\n contacts,\n newThisWeek,\n pipelineCents,\n tasksDue,\n leadsToWork,\n }))\n }, [firestore, scope, tokens, nowMs, leadSitesKey])\n\n const routes = useMemo(\n () =>\n hostId\n ? consoleRoute.base\n ? crmRoutes(`${consoleRoute.base}/crm`)\n : null\n : basePath\n ? crmRoutes(basePath)\n : null,\n [hostId, consoleRoute.base, basePath],\n )\n const hubHref = hostId\n ? consoleRoute.base\n ? `${consoleRoute.base}/crm`\n : undefined\n : basePath\n const value = figures.value\n\n return (\n <CardDisplay\n header={'CRM at a glance'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#crm-at-a-glance',\n excerpt:\n 'Contacts, new contacts this week, the value of every open deal, ' +\n 'the tasks due today or overdue, and the leads still to work — ' +\n 'each counted on the server and each a link into the CRM.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={hubHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Open CRM'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Contacts'}\n value={value ? value.contacts.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'New this week'}\n value={value ? value.newThisWeek.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'Open pipeline'}\n value={value ? money(value.pipelineCents, 'usd') : null}\n href={routes?.section('deals')}\n />\n <ReportStatTile\n label={'Tasks due'}\n value={value ? value.tasksDue.toLocaleString() : null}\n note={'today or overdue'}\n color={value?.tasksDue ? 'warning.main' : undefined}\n href={routes?.section('tasks')}\n />\n <ReportStatTile\n label={'Leads to work'}\n value={value ? value.leadsToWork.toLocaleString() : null}\n note={hostId ? 'open on this site' : 'open across every site'}\n href={routes?.section('leads')}\n />\n </Stack>\n {figures.status === 'error' ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The CRM figures could not be read. Open the CRM for the full picture.'}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCrmGlanceCard.displayName = 'CrmGlanceCard'\n\nexport default CrmGlanceCard\n"],"names":["Aglyn","money","AppLink","CardDisplay","Button","Stack","Typography","getAggregateFromServer","getCountFromServer","query","sum","Timestamp","where","useMemo","useConsoleHostRoute","useFirestore","useOrgDataScope","useOrgPlan","useCrmOrgMount","crmRoutes","openLeadsAcrossSites","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","WEEK_MS","CrmGlanceCard","props","hostId","basePath","mount","consoleRoute","org","ready","planReady","undefined","scope","orgId","firestore","tokens","group","consentGroupForHost","ORG_SCOPE_TOKEN","hostIds","map","id","hostScopeToken","slice","MAX_SCOPE_HOSTS","leadSites","hostsReady","hosts","host","leadSitesKey","join","nowMs","Date","now","figures","contacts","deals","CRM_COLLECTIONS","tasks","day","localDayBounds","countOf","target","then","snapshot","data","count","Promise","all","fromMillis","amountCents","Number","end","newThisWeek","pipelineCents","tasksDue","leadsToWork","routes","base","hubHref","value","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","size","color","spacing","direction","sx","flexWrap","label","toLocaleString","section","note","status","variant","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-glance-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 { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Button, Stack, Typography } from '@mui/material'\nimport {\n getAggregateFromServer,\n getCountFromServer,\n query,\n sum,\n Timestamp,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useOrgDataScope,\n useOrgPlan,\n} from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { crmRoutes } from '../model/crm-routes'\nimport { openLeadsAcrossSites } from './reports/lead-counts'\nimport { scopedCollection, visibleToClause } from './reports/report-scope'\nimport { ReportStatTile } from './reports/report-stat-tile'\nimport { useAggregateRead } from './reports/use-aggregate-read'\n\nconst WEEK_MS = 7 * 24 * 60 * 60 * 1000\n\ninterface GlanceFigures {\n contacts: number\n newThisWeek: number\n pipelineCents: number\n tasksDue: number\n /** Open leads — new or being worked — on this site, or across the org's sites. */\n leadsToWork: number\n}\n\nexport interface CrmGlanceCardProps {\n /**\n * The site the card is on the dashboard of, or `null` beneath the org\n * hub's mount (AGL-2634), where the figures total every site: the\n * org-wide reads carry no scope clause, and the lead figure fans out\n * over the mount's site list.\n */\n hostId: string | null\n /** The org hub's own path, for the links when there is no site to derive them from. */\n basePath?: string\n}\n\n/**\n * CRM at a glance (AGL-2604): a `hostDashboard` widget with five numbers —\n * contacts, new this week, open pipeline value, tasks due today or overdue,\n * and leads still to work (AGL-2624) — each a link into the section that\n * explains it.\n *\n * Server aggregates and nothing else: a dashboard card mounts on every\n * visit to the site's front page, so it is the one surface on which a\n * bounded read of a thousand rows would be a cost paid for nothing. Each\n * number is a count or a sum the server takes over the same `visibleTo`\n * predicate the hub's sections query with, so the card never shows a figure\n * the section behind the link would not.\n *\n * The lead figure is two counts and a subtraction — every lead on the site,\n * less the closed ones — because a lead nobody has touched carries no status\n * field and Firestore cannot select on a field's absence; see\n * `openLeadsFromCounts`. Leads are host-scoped by path rather than by\n * `visibleTo`, so those two counts run on `hosts/{hostId}/leads` under the\n * site's own rules, the way the Leads section reads it.\n *\n * The widget is handed a host doc id and nothing else, so the org — needed\n * for the consent group whose tokens scope the reads — comes from\n * `useOrgPlan`, and the link base from `useConsoleHostRoute`, the way the\n * commerce glance card builds its store link. Nothing is read until the org\n * has settled: a first round of aggregates against the solo group, replaced\n * by a second against the real one, would be two reads for one answer.\n *\n * ## The organization level (AGL-2634)\n *\n * Mounted with no site beneath the org hub's mount, the card totals the\n * organization: the reads carry no scope clause — the rules admit an\n * org-wide member to every row — and the lead figure is the same two\n * counts per site, over every site the mount lists, summed. Nothing is\n * read until that list has settled, for the reason nothing is read under\n * a site until the org has. The org's dashboard has no widget slot yet, so\n * the mount is a capability rather than a page; what it needs from the\n * shell is a slot and the hub's `basePath`.\n */\nexport function CrmGlanceCard(props: CrmGlanceCardProps) {\n const { hostId, basePath } = props\n const mount = useCrmOrgMount()\n const consoleRoute = useConsoleHostRoute(hostId)\n const { org, ready: planReady } = useOrgPlan(hostId ?? undefined)\n const { scope } = useOrgDataScope(hostId ? { hostId } : { orgId: mount?.orgId })\n const firestore = useFirestore()\n // Under a site, the site's group's tokens once the org has settled; at\n // the org level `null` — no clause — once the site list has.\n const tokens = useMemo(() => {\n if (!hostId) return null\n if (!planReady) return undefined\n const group = Aglyn.consentGroupForHost(org ?? null, hostId)\n return [\n Aglyn.ORG_SCOPE_TOKEN,\n ...group.hostIds.map((id) => Aglyn.hostScopeToken(id)),\n ].slice(0, Aglyn.MAX_SCOPE_HOSTS)\n }, [org, planReady, hostId])\n const leadSites = useMemo(\n () =>\n hostId\n ? [hostId]\n : mount?.hostsReady\n ? mount.hosts.map((host) => host.id)\n : null,\n [hostId, mount],\n )\n const leadSitesKey = leadSites?.join('\\n') ?? ''\n // Anchored once per mount so the queries are stable across renders.\n const nowMs = useMemo(() => Date.now(), [])\n\n const figures = useAggregateRead<GlanceFigures>(() => {\n if (!scope || tokens === undefined || !leadSites) return null\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const deals = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.deals)\n const tasks = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks)\n const day = Aglyn.localDayBounds(nowMs)\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n return Promise.all([\n countOf(query(contacts, ...visibleToClause(tokens))),\n countOf(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(nowMs - WEEK_MS)),\n ),\n ),\n getAggregateFromServer(\n query(deals, ...visibleToClause(tokens), where('status', '==', 'open')),\n { amountCents: sum('amountCents') },\n ).then((snapshot) => Number(snapshot.data().amountCents ?? 0)),\n // Due today OR overdue: everything open that is due before the day ends.\n countOf(\n query(\n tasks,\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n where('dueAtMs', '<', day.end),\n ),\n ),\n openLeadsAcrossSites(firestore, leadSites),\n ]).then(([contacts, newThisWeek, pipelineCents, tasksDue, leadsToWork]) => ({\n contacts,\n newThisWeek,\n pipelineCents,\n tasksDue,\n leadsToWork,\n }))\n }, [firestore, scope, tokens, nowMs, leadSitesKey])\n\n const routes = useMemo(\n () =>\n hostId\n ? consoleRoute.base\n ? crmRoutes(`${consoleRoute.base}/crm`)\n : null\n : basePath\n ? crmRoutes(basePath)\n : null,\n [hostId, consoleRoute.base, basePath],\n )\n const hubHref = hostId\n ? consoleRoute.base\n ? `${consoleRoute.base}/crm`\n : undefined\n : basePath\n const value = figures.value\n\n return (\n <CardDisplay\n header={'CRM at a glance'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#crm-at-a-glance',\n excerpt:\n 'Contacts, new contacts this week, the value of every open deal, ' +\n 'the tasks due today or overdue, and the leads still to work — ' +\n 'each counted on the server and each a link into the CRM.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={hubHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Open CRM'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Contacts'}\n value={value ? value.contacts.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'New this week'}\n value={value ? value.newThisWeek.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'Open pipeline'}\n value={value ? money(value.pipelineCents, 'usd') : null}\n href={routes?.section('deals')}\n />\n <ReportStatTile\n label={'Tasks due'}\n value={value ? value.tasksDue.toLocaleString() : null}\n note={'today or overdue'}\n color={value?.tasksDue ? 'warning.main' : undefined}\n href={routes?.section('tasks')}\n />\n <ReportStatTile\n label={'Leads to work'}\n value={value ? value.leadsToWork.toLocaleString() : null}\n note={hostId ? 'open on this site' : 'open across every site'}\n href={routes?.section('leads')}\n />\n </Stack>\n {figures.status === 'error' ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The CRM figures could not be read. Open the CRM for the full picture.'}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCrmGlanceCard.displayName = 'CrmGlanceCard'\n\nexport default CrmGlanceCard\n"],"names":["Aglyn","money","AppLink","CardDisplay","Button","Stack","Typography","getAggregateFromServer","getCountFromServer","query","sum","Timestamp","where","useMemo","useConsoleHostRoute","useFirestore","useOrgDataScope","useOrgPlan","useCrmOrgMount","crmRoutes","openLeadsAcrossSites","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","WEEK_MS","CrmGlanceCard","props","hostId","basePath","mount","consoleRoute","org","ready","planReady","undefined","scope","orgId","firestore","tokens","group","consentGroupForHost","ORG_SCOPE_TOKEN","hostIds","map","id","hostScopeToken","slice","MAX_SCOPE_HOSTS","leadSites","hostsReady","hosts","host","leadSitesKey","join","nowMs","Date","now","figures","contacts","deals","CRM_COLLECTIONS","tasks","day","localDayBounds","countOf","target","then","snapshot","data","count","Promise","all","fromMillis","amountCents","Number","end","newThisWeek","pipelineCents","tasksDue","leadsToWork","routes","base","hubHref","value","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","size","color","spacing","direction","sx","flexWrap","label","toLocaleString","section","note","status","variant","displayName"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  import * as Aglyn from "@aglyn/aglyn";
19
- import { money } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
19
+ import { money } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
20
20
  import { CardDisplay } from "@aglyn/shared-ui-jsx";
21
21
  import { measuredRate, percent, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
22
22
  import { Alert, Stack, Typography } from "@mui/material";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/closed-deals-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 { money } from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n measuredRate,\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport {\n count,\n getAggregateFromServer,\n getCountFromServer,\n limit,\n orderBy,\n query,\n sum,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many closed deals of each outcome the weekly chart reads.\n *\n * The tiles are server aggregates and never meet this bound; the chart\n * needs each deal's `closedAtMs` to place it in a week, so it reads them,\n * most recent first, and says when the period held more.\n */\nconst CLOSED_DEAL_CEILING = 500\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface ClosedDealsCardProps {\n report: CrmReportScope\n}\n\n/**\n * Deals won and lost in the period, and the win rate over what closed\n * (AGL-2604).\n *\n * Won and lost are TWO queries rather than one over `status in [won,\n * lost]`: each is an equality the `(visibleTo, status, closedAtMs)` index\n * serves directly, and a card that drew one series from a merged read\n * would have to split it again by hand. The range is on `closedAtMs`, the\n * moment the deal was closed — not `updatedAt`, which a note added last\n * week would move.\n */\nexport function ClosedDealsCard(props: ClosedDealsCardProps) {\n const { report } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const closedInPeriod = (status: Aglyn.CrmDealStatus) =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', status),\n where('closedAtMs', '>=', range.from),\n where('closedAtMs', '<', range.to),\n )\n\n const closed = useAggregateRead(\n () =>\n Promise.all([\n getAggregateFromServer(closedInPeriod('won'), {\n count: count(),\n amountCents: sum('amountCents'),\n }).then((snapshot) => ({\n count: Number(snapshot.data().count ?? 0),\n amountCents: Number(snapshot.data().amountCents ?? 0),\n })),\n getCountFromServer(closedInPeriod('lost')).then(\n (snapshot) => snapshot.data().count,\n ),\n ]).then(([won, lost]) => ({ won, lost })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:totals') },\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('won'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('lost'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n const wonStatus = wonWindow.status\n const lostStatus = lostWindow.status\n\n const chart = useMemo(() => {\n const wonWeeks = Aglyn.bucketByWeek(\n wonWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n const lostWeeks = Aglyn.bucketByWeek(\n lostWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n return {\n labels: wonWeeks.map((week) => weekLabel(week.start)),\n won: wonWeeks.map((week) => week.items.length),\n wonCents: wonWeeks.map((week) =>\n week.items.reduce(\n (total, deal) => total + Math.max(0, Number(deal.amountCents ?? 0)),\n 0,\n ),\n ),\n lost: lostWeeks.map((week) => week.items.length),\n currency: Aglyn.currencyOfDeals(wonWindow.rows).currency,\n }\n }, [wonWindow, lostWindow, range])\n\n const figures = closed.value\n const winRate = figures\n ? measuredRate(figures.won.count, figures.won.count + figures.lost, 'closed')\n : null\n const chartRead = wonStatus === 'success' && lostStatus === 'success'\n\n return (\n <CardDisplay\n header={'Won and lost'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost',\n excerpt:\n 'Deals closed in the period, by outcome and by week, with the ' +\n 'value of what was won and the win rate over everything that ' +\n 'closed. Placed by the moment each deal was closed.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Won'}\n value={figures ? figures.won.count.toLocaleString() : null}\n note={\n figures\n ? `${money(figures.won.amountCents, chart.currency)} closed won`\n : undefined\n }\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Lost'}\n value={figures ? figures.lost.toLocaleString() : null}\n note={'in the period'}\n riseIsGood={false}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Win rate'}\n value={winRate ? percent(winRate.value) : null}\n note={\n winRate\n ? `${winRate.numerator.toLocaleString()} of ${winRate.denominator.toLocaleString()} ${winRate.denominatorLabel}`\n : figures\n ? 'nothing closed in the period'\n : undefined\n }\n />\n </Stack>\n {closed.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The closed-deal totals could not be read.'}\n </Alert>\n ) : null}\n <Section title={'Won vs lost per week'}>\n <ReportBarChart\n labels={chart.labels}\n series={[\n { name: 'Won', color: 'success.main', values: chart.won },\n { name: 'Lost', color: 'error.main', values: chart.lost },\n ]}\n format={(value, seriesIndex, barIndex) =>\n seriesIndex === 0\n ? `Won: ${value.toLocaleString()} · ${money(chart.wonCents[barIndex] ?? 0, chart.currency)}`\n : `Lost: ${value.toLocaleString()}`\n }\n emptyText={chartRead ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`The chart places the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nClosedDealsCard.displayName = 'ClosedDealsCard'\n\nexport default ClosedDealsCard\n"],"names":["Aglyn","money","CardDisplay","measuredRate","percent","Section","Alert","Stack","Typography","count","getAggregateFromServer","getCountFromServer","limit","orderBy","query","sum","where","useMemo","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","CLOSED_DEAL_CEILING","ClosedDealsCard","props","report","scope","tokens","range","routes","firestore","closedInPeriod","status","from","to","closed","Promise","all","amountCents","then","snapshot","Number","data","won","lost","cacheKey","wonWindow","lostWindow","wonStatus","lostStatus","chart","wonWeeks","bucketByWeek","rows","deal","closedAtMs","lostWeeks","labels","map","week","start","items","length","wonCents","reduce","total","Math","max","currency","currencyOfDeals","figures","value","winRate","chartRead","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","note","undefined","href","section","riseIsGood","numerator","denominator","denominatorLabel","severity","title","series","name","color","values","format","seriesIndex","barIndex","emptyText","truncated","variant","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/closed-deals-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 { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n measuredRate,\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport {\n count,\n getAggregateFromServer,\n getCountFromServer,\n limit,\n orderBy,\n query,\n sum,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many closed deals of each outcome the weekly chart reads.\n *\n * The tiles are server aggregates and never meet this bound; the chart\n * needs each deal's `closedAtMs` to place it in a week, so it reads them,\n * most recent first, and says when the period held more.\n */\nconst CLOSED_DEAL_CEILING = 500\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface ClosedDealsCardProps {\n report: CrmReportScope\n}\n\n/**\n * Deals won and lost in the period, and the win rate over what closed\n * (AGL-2604).\n *\n * Won and lost are TWO queries rather than one over `status in [won,\n * lost]`: each is an equality the `(visibleTo, status, closedAtMs)` index\n * serves directly, and a card that drew one series from a merged read\n * would have to split it again by hand. The range is on `closedAtMs`, the\n * moment the deal was closed — not `updatedAt`, which a note added last\n * week would move.\n */\nexport function ClosedDealsCard(props: ClosedDealsCardProps) {\n const { report } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const closedInPeriod = (status: Aglyn.CrmDealStatus) =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', status),\n where('closedAtMs', '>=', range.from),\n where('closedAtMs', '<', range.to),\n )\n\n const closed = useAggregateRead(\n () =>\n Promise.all([\n getAggregateFromServer(closedInPeriod('won'), {\n count: count(),\n amountCents: sum('amountCents'),\n }).then((snapshot) => ({\n count: Number(snapshot.data().count ?? 0),\n amountCents: Number(snapshot.data().amountCents ?? 0),\n })),\n getCountFromServer(closedInPeriod('lost')).then(\n (snapshot) => snapshot.data().count,\n ),\n ]).then(([won, lost]) => ({ won, lost })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:totals') },\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('won'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('lost'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n const wonStatus = wonWindow.status\n const lostStatus = lostWindow.status\n\n const chart = useMemo(() => {\n const wonWeeks = Aglyn.bucketByWeek(\n wonWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n const lostWeeks = Aglyn.bucketByWeek(\n lostWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n return {\n labels: wonWeeks.map((week) => weekLabel(week.start)),\n won: wonWeeks.map((week) => week.items.length),\n wonCents: wonWeeks.map((week) =>\n week.items.reduce(\n (total, deal) => total + Math.max(0, Number(deal.amountCents ?? 0)),\n 0,\n ),\n ),\n lost: lostWeeks.map((week) => week.items.length),\n currency: Aglyn.currencyOfDeals(wonWindow.rows).currency,\n }\n }, [wonWindow, lostWindow, range])\n\n const figures = closed.value\n const winRate = figures\n ? measuredRate(figures.won.count, figures.won.count + figures.lost, 'closed')\n : null\n const chartRead = wonStatus === 'success' && lostStatus === 'success'\n\n return (\n <CardDisplay\n header={'Won and lost'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost',\n excerpt:\n 'Deals closed in the period, by outcome and by week, with the ' +\n 'value of what was won and the win rate over everything that ' +\n 'closed. Placed by the moment each deal was closed.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Won'}\n value={figures ? figures.won.count.toLocaleString() : null}\n note={\n figures\n ? `${money(figures.won.amountCents, chart.currency)} closed won`\n : undefined\n }\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Lost'}\n value={figures ? figures.lost.toLocaleString() : null}\n note={'in the period'}\n riseIsGood={false}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Win rate'}\n value={winRate ? percent(winRate.value) : null}\n note={\n winRate\n ? `${winRate.numerator.toLocaleString()} of ${winRate.denominator.toLocaleString()} ${winRate.denominatorLabel}`\n : figures\n ? 'nothing closed in the period'\n : undefined\n }\n />\n </Stack>\n {closed.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The closed-deal totals could not be read.'}\n </Alert>\n ) : null}\n <Section title={'Won vs lost per week'}>\n <ReportBarChart\n labels={chart.labels}\n series={[\n { name: 'Won', color: 'success.main', values: chart.won },\n { name: 'Lost', color: 'error.main', values: chart.lost },\n ]}\n format={(value, seriesIndex, barIndex) =>\n seriesIndex === 0\n ? `Won: ${value.toLocaleString()} · ${money(chart.wonCents[barIndex] ?? 0, chart.currency)}`\n : `Lost: ${value.toLocaleString()}`\n }\n emptyText={chartRead ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`The chart places the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nClosedDealsCard.displayName = 'ClosedDealsCard'\n\nexport default ClosedDealsCard\n"],"names":["Aglyn","money","CardDisplay","measuredRate","percent","Section","Alert","Stack","Typography","count","getAggregateFromServer","getCountFromServer","limit","orderBy","query","sum","where","useMemo","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","CLOSED_DEAL_CEILING","ClosedDealsCard","props","report","scope","tokens","range","routes","firestore","closedInPeriod","status","from","to","closed","Promise","all","amountCents","then","snapshot","Number","data","won","lost","cacheKey","wonWindow","lostWindow","wonStatus","lostStatus","chart","wonWeeks","bucketByWeek","rows","deal","closedAtMs","lostWeeks","labels","map","week","start","items","length","wonCents","reduce","total","Math","max","currency","currencyOfDeals","figures","value","winRate","chartRead","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","note","undefined","href","section","riseIsGood","numerator","denominator","denominatorLabel","severity","title","series","name","color","values","format","seriesIndex","barIndex","emptyText","truncated","variant","displayName"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  import * as Aglyn from "@aglyn/aglyn";
19
- import { money } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
19
+ import { money } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
20
20
  import { CardDisplay } from "@aglyn/shared-ui-jsx";
21
21
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
22
22
  import { Box, Stack, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/forecast-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 { money } from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Box,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\n\n/**\n * The same window the pipeline card reads, under the SAME cache key and\n * the same dependencies, so the two cards cost one read between them —\n * `useAggregateRead` joins a read already in the air for the key. The\n * numbers must match the pipeline card's query exactly or the key would\n * name two different questions.\n */\nconst OPEN_DEAL_CEILING = 1000\nconst PIPELINE_CEILING = 20\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface ForecastCardProps {\n report: CrmReportScope\n}\n\n/** `Sep 2026` — the month a column is. */\nfunction monthLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })\n}\n\n/**\n * Forecast by close month (AGL-2620): the open pipeline laid out by the\n * month each deal is expected to close, for the next six, per pipeline,\n * at face value and weighted by the stage.\n *\n * Not period-scoped, like the pipeline card and for the same reason: the\n * forecast is what is open NOW, looked at forward. Deals with no expected\n * close are their own row rather than a footnote — that row is the size\n * of the pipeline nobody has scheduled, which is the number the picker\n * of a close date most needs to see — and deals dated before this month\n * (overdue) or past the sixth (later) are rows too, so the column adds up\n * to the pipeline.\n */\nexport function ForecastCard(props: ForecastCardProps) {\n const { report } = props\n const { scope, tokens, nowMs } = report\n const firestore = useFirestore()\n\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(scopedCollection(firestore, scope, 'pipelines'), ...visibleToClause(tokens)),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n\n const forecast = useMemo(\n () => Aglyn.forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs),\n [dealWindow.rows, pipelineWindow.rows, nowMs],\n )\n const currency = useMemo(() => Aglyn.currencyOfDeals(dealWindow.rows), [dealWindow.rows])\n const read = dealWindow.status === 'success' && pipelineWindow.status === 'success'\n const failed = dealWindow.status === 'error' || pipelineWindow.status === 'error'\n\n // A pipeline with nothing open is still a column; an unknown pipeline id\n // is a column named for what it is.\n const columns = forecast.pipelines\n const manyColumns = columns.length > 1\n\n const rows: Array<{ key: string; label: string; cellOf: (row: Aglyn.PipelineForecast) => Aglyn.ForecastCell; total: Aglyn.ForecastCell; muted?: boolean }> = [\n {\n key: 'overdue',\n label: 'Before this month',\n cellOf: (row) => row.overdue,\n total: forecast.overdue,\n muted: true,\n },\n ...forecast.buckets.map((bucket, index) => ({\n key: bucket.key,\n label: monthLabel(bucket.start),\n cellOf: (row: Aglyn.PipelineForecast) => row.months[index],\n total: forecast.months[index],\n })),\n {\n key: 'later',\n label: 'Later',\n cellOf: (row) => row.later,\n total: forecast.later,\n muted: true,\n },\n {\n key: 'undated',\n label: 'No expected close',\n cellOf: (row) => row.undated,\n total: forecast.undated,\n muted: true,\n },\n ].filter((row) => row.key !== 'overdue' && row.key !== 'later' ? true : row.total.count > 0)\n\n const cell = (value: Aglyn.ForecastCell, code: string) =>\n value.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.disabled\">\n {'—'}\n </Typography>\n ) : (\n <Stack sx={{ lineHeight: 1.2 }}>\n <Typography variant=\"body2\">{money(value.amountCents, code)}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(value.weightedCents, code)} weighted · ${plural(value.count, 'deal')}`}\n </Typography>\n </Stack>\n )\n\n return (\n <CardDisplay\n header={'Forecast by close month'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#forecast-by-close-month',\n excerpt:\n 'Every open deal by the month it is expected to close, for the ' +\n 'next six months and per pipeline, at face value and weighted by ' +\n 'its stage. Deals with no expected close are their own row.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Typography variant=\"body2\" color=\"warning.main\">\n {'The forecast could not be read.'}\n </Typography>\n ) : !read ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ) : forecast.total.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : (\n <Box>\n <ScrollTable size=\"small\" aria-label=\"Forecast by close month\">\n <TableHead>\n <TableRow>\n <TableCell>{'Expected close'}</TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {column.name || 'Pipeline not visible'}\n </TableCell>\n ))}\n {manyColumns ? <TableCell align=\"right\">{'All pipelines'}</TableCell> : null}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key} data-forecast-row={row.key}>\n <TableCell>\n <Typography variant=\"body2\" color={row.muted ? 'text.secondary' : undefined}>\n {row.label}\n </Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(row.cellOf(column), currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(row.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n ))}\n <TableRow>\n <TableCell>\n <Typography variant=\"subtitle2\">{'Open pipeline'}</Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(column.total, currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(forecast.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n </TableBody>\n </ScrollTable>\n </Box>\n )}\n {read && currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the figures add them as numbers and show ${currency.currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {read && dealWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`From the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`}\n </Typography>\n ) : null}\n {read && pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are named.`}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nForecastCard.displayName = 'ForecastCard'\n\nexport default ForecastCard\n"],"names":["Aglyn","money","CardDisplay","ScrollTable","Box","Stack","TableBody","TableCell","TableHead","TableRow","Typography","limit","orderBy","query","where","useMemo","useFirestore","collectionCeiling","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","OPEN_DEAL_CEILING","PIPELINE_CEILING","monthLabel","startMs","Date","toLocaleDateString","undefined","month","year","ForecastCard","props","report","scope","tokens","nowMs","firestore","dealWindow","cacheKey","pipelineWindow","forecast","forecastByCloseMonth","rows","currency","currencyOfDeals","read","status","failed","columns","pipelines","manyColumns","length","key","label","cellOf","row","overdue","total","muted","buckets","map","bucket","index","start","months","later","undated","filter","count","cell","value","code","variant","color","sx","lineHeight","amountCents","weightedCents","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","size","aria-label","column","align","name","pipelineId","data-forecast-row","mixed","toUpperCase","truncated","toLocaleString","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/forecast-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 { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Box,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\n\n/**\n * The same window the pipeline card reads, under the SAME cache key and\n * the same dependencies, so the two cards cost one read between them —\n * `useAggregateRead` joins a read already in the air for the key. The\n * numbers must match the pipeline card's query exactly or the key would\n * name two different questions.\n */\nconst OPEN_DEAL_CEILING = 1000\nconst PIPELINE_CEILING = 20\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface ForecastCardProps {\n report: CrmReportScope\n}\n\n/** `Sep 2026` — the month a column is. */\nfunction monthLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })\n}\n\n/**\n * Forecast by close month (AGL-2620): the open pipeline laid out by the\n * month each deal is expected to close, for the next six, per pipeline,\n * at face value and weighted by the stage.\n *\n * Not period-scoped, like the pipeline card and for the same reason: the\n * forecast is what is open NOW, looked at forward. Deals with no expected\n * close are their own row rather than a footnote — that row is the size\n * of the pipeline nobody has scheduled, which is the number the picker\n * of a close date most needs to see — and deals dated before this month\n * (overdue) or past the sixth (later) are rows too, so the column adds up\n * to the pipeline.\n */\nexport function ForecastCard(props: ForecastCardProps) {\n const { report } = props\n const { scope, tokens, nowMs } = report\n const firestore = useFirestore()\n\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(scopedCollection(firestore, scope, 'pipelines'), ...visibleToClause(tokens)),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n\n const forecast = useMemo(\n () => Aglyn.forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs),\n [dealWindow.rows, pipelineWindow.rows, nowMs],\n )\n const currency = useMemo(() => Aglyn.currencyOfDeals(dealWindow.rows), [dealWindow.rows])\n const read = dealWindow.status === 'success' && pipelineWindow.status === 'success'\n const failed = dealWindow.status === 'error' || pipelineWindow.status === 'error'\n\n // A pipeline with nothing open is still a column; an unknown pipeline id\n // is a column named for what it is.\n const columns = forecast.pipelines\n const manyColumns = columns.length > 1\n\n const rows: Array<{ key: string; label: string; cellOf: (row: Aglyn.PipelineForecast) => Aglyn.ForecastCell; total: Aglyn.ForecastCell; muted?: boolean }> = [\n {\n key: 'overdue',\n label: 'Before this month',\n cellOf: (row) => row.overdue,\n total: forecast.overdue,\n muted: true,\n },\n ...forecast.buckets.map((bucket, index) => ({\n key: bucket.key,\n label: monthLabel(bucket.start),\n cellOf: (row: Aglyn.PipelineForecast) => row.months[index],\n total: forecast.months[index],\n })),\n {\n key: 'later',\n label: 'Later',\n cellOf: (row) => row.later,\n total: forecast.later,\n muted: true,\n },\n {\n key: 'undated',\n label: 'No expected close',\n cellOf: (row) => row.undated,\n total: forecast.undated,\n muted: true,\n },\n ].filter((row) => row.key !== 'overdue' && row.key !== 'later' ? true : row.total.count > 0)\n\n const cell = (value: Aglyn.ForecastCell, code: string) =>\n value.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.disabled\">\n {'—'}\n </Typography>\n ) : (\n <Stack sx={{ lineHeight: 1.2 }}>\n <Typography variant=\"body2\">{money(value.amountCents, code)}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(value.weightedCents, code)} weighted · ${plural(value.count, 'deal')}`}\n </Typography>\n </Stack>\n )\n\n return (\n <CardDisplay\n header={'Forecast by close month'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#forecast-by-close-month',\n excerpt:\n 'Every open deal by the month it is expected to close, for the ' +\n 'next six months and per pipeline, at face value and weighted by ' +\n 'its stage. Deals with no expected close are their own row.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Typography variant=\"body2\" color=\"warning.main\">\n {'The forecast could not be read.'}\n </Typography>\n ) : !read ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ) : forecast.total.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : (\n <Box>\n <ScrollTable size=\"small\" aria-label=\"Forecast by close month\">\n <TableHead>\n <TableRow>\n <TableCell>{'Expected close'}</TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {column.name || 'Pipeline not visible'}\n </TableCell>\n ))}\n {manyColumns ? <TableCell align=\"right\">{'All pipelines'}</TableCell> : null}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key} data-forecast-row={row.key}>\n <TableCell>\n <Typography variant=\"body2\" color={row.muted ? 'text.secondary' : undefined}>\n {row.label}\n </Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(row.cellOf(column), currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(row.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n ))}\n <TableRow>\n <TableCell>\n <Typography variant=\"subtitle2\">{'Open pipeline'}</Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(column.total, currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(forecast.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n </TableBody>\n </ScrollTable>\n </Box>\n )}\n {read && currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the figures add them as numbers and show ${currency.currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {read && dealWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`From the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`}\n </Typography>\n ) : null}\n {read && pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are named.`}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nForecastCard.displayName = 'ForecastCard'\n\nexport default ForecastCard\n"],"names":["Aglyn","money","CardDisplay","ScrollTable","Box","Stack","TableBody","TableCell","TableHead","TableRow","Typography","limit","orderBy","query","where","useMemo","useFirestore","collectionCeiling","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","OPEN_DEAL_CEILING","PIPELINE_CEILING","monthLabel","startMs","Date","toLocaleDateString","undefined","month","year","ForecastCard","props","report","scope","tokens","nowMs","firestore","dealWindow","cacheKey","pipelineWindow","forecast","forecastByCloseMonth","rows","currency","currencyOfDeals","read","status","failed","columns","pipelines","manyColumns","length","key","label","cellOf","row","overdue","total","muted","buckets","map","bucket","index","start","months","later","undated","filter","count","cell","value","code","variant","color","sx","lineHeight","amountCents","weightedCents","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","size","aria-label","column","align","name","pipelineId","data-forecast-row","mixed","toUpperCase","truncated","toLocaleString","displayName"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  import * as Aglyn from "@aglyn/aglyn";
19
- import { money } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
19
+ import { money } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
20
20
  import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
21
21
  import { Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
22
22
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/pipeline-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 { money } from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n count,\n getAggregateFromServer,\n limit,\n orderBy,\n query,\n sum,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename, shortDate } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many open deals the by-stage chart, the forecast and the top-deals\n * table are read from.\n *\n * The count and the face value of the pipeline are server aggregates and\n * never meet this bound. The forecast cannot be: it weights each deal by\n * ITS stage's odds, which is a join the server will not do, so it reads the\n * deals and says so when there were more than this.\n */\nconst OPEN_DEAL_CEILING = 1000\n/** More pipelines than this is a shape nobody has asked for; the probe says if it happens. */\nconst PIPELINE_CEILING = 20\nconst TOP_DEALS = 10\n\n/** The top-deals table's columns, which are also the CSV's — the amount split from its currency there. */\nconst TOP_DEAL_COLUMNS = ['Deal', 'Stage', 'Expected close', 'Amount', 'Currency'] as const\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface PipelineCardProps {\n report: CrmReportScope\n}\n\n/**\n * The open pipeline: how many deals, what they add up to, what they are\n * worth at their stage odds, where they sit, and the largest of them\n * (AGL-2604).\n *\n * Not period-scoped, deliberately. A pipeline is a stock, not a flow — what\n * is open NOW — and a \"pipeline over the last 30 days\" would be a report on\n * deals created in a window, which is a different question with a different\n * answer. The period picker governs the cards that measure flows.\n */\nexport function PipelineCard(props: PipelineCardProps) {\n const { report } = props\n const { scope, tokens, routes, nowMs } = report\n const firestore = useFirestore()\n\n const open = useAggregateRead(\n () =>\n getAggregateFromServer(\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n ),\n { count: count(), amountCents: sum('amountCents') },\n ).then((snapshot) => ({\n count: Number(snapshot.data().count ?? 0),\n amountCents: Number(snapshot.data().amountCents ?? 0),\n })),\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:open') },\n )\n\n /**\n * The open deals, most recently updated first — the order the\n * `(visibleTo, status, updatedAt)` index the deals list already uses can\n * serve, so the report adds no index for this read.\n */\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(\n scopedCollection(firestore, scope, 'pipelines'),\n ...visibleToClause(tokens),\n ),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n const dealsStatus = dealWindow.status\n\n const summary = useMemo(() => {\n const byPipeline = new Map<string, DealRow[]>()\n for (const deal of dealWindow.rows) {\n const key = deal.pipelineId ?? ''\n const list = byPipeline.get(key) ?? []\n list.push(deal)\n byPipeline.set(key, list)\n }\n // Every active pipeline is charted even with nothing open; an archived\n // one only while a deal still sits open in it — the forecast card's\n // rule, so the two cards name the same pipelines.\n const pipelines = pipelineWindow.rows\n .filter((pipeline) => !Aglyn.isPipelineArchived(pipeline) || byPipeline.has(pipeline.$id))\n .map((pipeline) => ({\n pipeline,\n totals: Aglyn.pipelineTotals(byPipeline.get(pipeline.$id) ?? [], pipeline),\n }))\n const known = new Set(pipelineWindow.rows.map((pipeline) => pipeline.$id))\n const orphans = dealWindow.rows.filter(\n (deal) => !known.has(deal.pipelineId ?? ''),\n )\n const stageName = (deal: DealRow): string =>\n Aglyn.dealStageById(\n pipelineWindow.rows.find((pipeline) => pipeline.$id === deal.pipelineId),\n deal.stageId,\n )?.name ?? deal.stageId\n const top = [...dealWindow.rows]\n .sort(\n (a, b) => Number(b.amountCents ?? 0) - Number(a.amountCents ?? 0),\n )\n .slice(0, TOP_DEALS)\n return {\n pipelines,\n orphanTotals: Aglyn.pipelineTotals(orphans, null),\n weightedCents: pipelines.reduce(\n (total, entry) => total + entry.totals.weightedCents,\n 0,\n ),\n top,\n stageName,\n currency: Aglyn.currencyOfDeals(dealWindow.rows),\n }\n }, [dealWindow, pipelineWindow])\n // Open deals with nothing scheduled (AGL-2661), off the same window.\n const stuck = useMemo(() => Aglyn.stuckDeals(dealWindow.rows), [dealWindow.rows])\n\n const currency = summary.currency.currency\n const dealsRead = dealsStatus === 'success'\n\n return (\n <CardDisplay\n header={'Pipeline'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#pipeline',\n excerpt:\n 'Every open deal: how many, what they add up to, and what they ' +\n 'are worth once each is weighted by the odds of its stage. Not ' +\n 'tied to the period — a pipeline is what is open now.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Open deals'}\n value={open.value ? open.value.count.toLocaleString() : null}\n note={'counted on the server'}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Pipeline value'}\n value={open.value ? money(open.value.amountCents, currency) : null}\n note={'face value of every open deal'}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Weighted forecast'}\n value={dealsRead ? money(summary.weightedCents, currency) : null}\n note={\n dealWindow.truncated\n ? `from the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated`\n : 'each deal at its stage odds'\n }\n />\n {/*\n Open deals with no open task against them (AGL-2661): the ones\n nobody has a next step for. Painted as a warning above zero,\n because a deal with nothing planned is a deal going nowhere; the\n tile opens the deals list, whose \"No next activity\" chip lists\n them.\n */}\n <ReportStatTile\n label={'Stuck deals'}\n value={dealsRead ? stuck.count.toLocaleString() : null}\n note={'open, with no next activity'}\n riseIsGood={false}\n color={stuck.count > 0 ? 'warning.main' : undefined}\n href={routes.section('deals')}\n />\n </Stack>\n {open.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The deal totals could not be read.'}\n </Alert>\n ) : null}\n {summary.currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the totals add them as numbers and show ${currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {dealsRead && !summary.pipelines.length && !dealWindow.rows.length ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : null}\n {summary.pipelines.map(({ pipeline, totals }) => (\n <Section key={pipeline.$id} title={pipeline.name || 'Pipeline'}>\n <ReportBreakdown\n rows={[\n ...totals.stages.map((row) => ({\n key: row.stage.id,\n label: row.stage.name,\n value: row.amountCents,\n display: money(row.amountCents, currency),\n note: `${plural(row.count, 'deal')} · ${money(row.weightedCents, currency)} weighted`,\n })),\n ...(totals.unplaced.count\n ? [\n {\n key: '$unplaced',\n label: 'Stage no longer in this pipeline',\n value: totals.unplaced.amountCents,\n display: money(totals.unplaced.amountCents, currency),\n note: `${plural(totals.unplaced.count, 'deal')} · not forecast`,\n color: 'warning.main',\n },\n ]\n : []),\n ]}\n emptyText={'This pipeline has no open stages.'}\n />\n </Section>\n ))}\n {summary.orphanTotals.count ? (\n <Section title={'Deals in no pipeline'}>\n <ReportBreakdown\n rows={[\n {\n key: '$orphans',\n label: 'No pipeline',\n value: summary.orphanTotals.amountCents,\n display: money(summary.orphanTotals.amountCents, currency),\n note: `${plural(summary.orphanTotals.count, 'deal')} · not forecast`,\n color: 'warning.main',\n },\n ]}\n emptyText={''}\n />\n </Section>\n ) : null}\n {pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are charted.`}\n </Typography>\n ) : null}\n {summary.top.length ? (\n <Section title={'Top open deals'}>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Deal'}</TableCell>\n <TableCell>{'Stage'}</TableCell>\n <TableCell>{'Expected close'}</TableCell>\n <TableCell align=\"right\">{'Amount'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {summary.top.map((deal) => (\n <TableRow key={deal.$id}>\n <TableCell>\n <AppLink href={routes.deal(deal.$id)}>\n {deal.title || deal.$id}\n </AppLink>\n </TableCell>\n <TableCell>{summary.stageName(deal)}</TableCell>\n <TableCell>{shortDate(deal.expectedCloseAtMs)}</TableCell>\n <TableCell align=\"right\">\n {money(\n Number(deal.amountCents ?? 0),\n String(deal.currency || currency),\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <ReportExport\n filename={reportFilename('top-open-deals')}\n columns={TOP_DEAL_COLUMNS}\n rows={() =>\n summary.top.map((deal) => [\n deal.title || deal.$id,\n summary.stageName(deal),\n typeof deal.expectedCloseAtMs === 'number' && deal.expectedCloseAtMs > 0\n ? new Date(deal.expectedCloseAtMs).toISOString().slice(0, 10)\n : '',\n (Number(deal.amountCents ?? 0) / 100).toFixed(2),\n String(deal.currency || currency).toUpperCase(),\n ])\n }\n disabled={!dealsRead}\n caption={\n dealWindow.truncated\n ? `Ranked within the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`\n : undefined\n }\n />\n </Section>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nPipelineCard.displayName = 'PipelineCard'\n\nexport default PipelineCard\n"],"names":["Aglyn","money","AppLink","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","count","getAggregateFromServer","limit","orderBy","query","sum","where","useMemo","useFirestore","collectionCeiling","ReportBreakdown","ReportExport","plural","reportFilename","shortDate","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","OPEN_DEAL_CEILING","PIPELINE_CEILING","TOP_DEALS","TOP_DEAL_COLUMNS","PipelineCard","props","report","scope","tokens","routes","nowMs","firestore","open","amountCents","then","snapshot","Number","data","cacheKey","dealWindow","pipelineWindow","dealsStatus","status","summary","byPipeline","Map","deal","rows","key","pipelineId","list","get","push","set","pipelines","filter","pipeline","isPipelineArchived","has","$id","map","totals","pipelineTotals","known","Set","orphans","stageName","dealStageById","find","stageId","name","top","sort","a","b","slice","orphanTotals","weightedCents","reduce","total","entry","currency","currencyOfDeals","stuck","stuckDeals","dealsRead","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","value","toLocaleString","note","href","section","truncated","riseIsGood","color","undefined","severity","mixed","variant","toUpperCase","length","title","stages","row","stage","id","display","unplaced","emptyText","size","align","expectedCloseAtMs","String","filename","columns","Date","toISOString","toFixed","disabled","caption","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,YAAYA,WAAW,eAAc;AACrC,SAASC,KAAK,QAAQ,6DAA4D;AAClF,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,OAAO,QAAQ,6DAA4D;AACpF,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SACEC,KAAK,EACLC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SACEC,KAAK,EACLC,sBAAsB,EACtBC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,GAAG,EACHC,KAAK,QACA,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,iBAAiB,QAAQ,+DAA8D;AAChG,SAASC,eAAe,QAAQ,wBAAoB;AACpD,SAASC,YAAY,QAAQ,qBAAiB;AAC9C,SAASC,MAAM,EAAEC,cAAc,EAAEC,SAAS,QAAQ,qBAAiB;AACnE,SAEEC,cAAc,EACdC,gBAAgB,EAChBC,eAAe,QACV,oBAAgB;AACvB,SAASC,cAAc,QAAQ,wBAAoB;AACnD,SAASC,gBAAgB,EAAEC,aAAa,QAAQ,0BAAsB;AAEtE;;;;;;;;CAQC,GACD,MAAMC,oBAAoB;AAC1B,4FAA4F,GAC5F,MAAMC,mBAAmB;AACzB,MAAMC,YAAY;AAElB,wGAAwG,GACxG,MAAMC,mBAAmB;IAAC;IAAQ;IAAS;IAAkB;IAAU;CAAW;AASlF;;;;;;;;;CASC,GACD,OAAO,SAASC,aAAaC,KAAwB;IACnD,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAM,EAAEE,KAAK,EAAEC,MAAM,EAAEC,MAAM,EAAEC,KAAK,EAAE,GAAGJ;IACzC,MAAMK,YAAYxB;IAElB,MAAMyB,OAAOd,iBACX,IACElB,uBACEG,MACEY,iBAAiBgB,WAAWJ,OAAO,aAChCX,gBAAgBY,SACnBvB,MAAM,UAAU,MAAM,UAExB;YAAEN,OAAOA;YAASkC,aAAa7B,IAAI;QAAe,GAClD8B,IAAI,CAAC,CAACC;gBACQA,sBACMA;mBAFA;gBACpBpC,OAAOqC,QAAOD,uBAAAA,SAASE,IAAI,GAAGtC,KAAK,YAArBoC,uBAAyB;gBACvCF,aAAaG,QAAOD,6BAAAA,SAASE,IAAI,GAAGJ,WAAW,YAA3BE,6BAA+B;YACrD;YACF;QAACJ;QAAWJ;QAAOC;QAAQE;KAAM,EACjC;QAAEQ,UAAUxB,eAAeY,QAAQ;IAAiB;IAGtD;;;;GAIC,GACD,MAAMa,aAAapB,cACjB,IACEhB,MACEY,iBAAiBgB,WAAWJ,OAAO,aAChCX,gBAAgBY,SACnBvB,MAAM,UAAU,MAAM,SACtBH,QAAQ,aAAa,SACrBD,MAAMmB,oBAAoB,KAE9BA,mBACA;QAACW;QAAWJ;QAAOC;QAAQE;KAAM,EACjC;QAAEQ,UAAUxB,eAAeY,QAAQ;IAAkB;IAEvD,MAAMc,iBAAiBrB,cACrB,IACEX,kBACEL,MACEY,iBAAiBgB,WAAWJ,OAAO,iBAChCX,gBAAgBY,UAErBP,mBAEJA,kBACA;QAACU;QAAWJ;QAAOC;QAAQE;KAAM,EACjC;QAAEQ,UAAUxB,eAAeY,QAAQ;IAAsB;IAE3D,MAAMe,cAAcF,WAAWG,MAAM;IAErC,MAAMC,UAAUrC,QAAQ;QACtB,MAAMsC,aAAa,IAAIC;QACvB,KAAK,MAAMC,QAAQP,WAAWQ,IAAI,CAAE;gBACtBD,kBACCF;YADb,MAAMI,OAAMF,mBAAAA,KAAKG,UAAU,YAAfH,mBAAmB;YAC/B,MAAMI,QAAON,kBAAAA,WAAWO,GAAG,CAACH,gBAAfJ,kBAAuB,EAAE;YACtCM,KAAKE,IAAI,CAACN;YACVF,WAAWS,GAAG,CAACL,KAAKE;QACtB;QACA,uEAAuE;QACvE,oEAAoE;QACpE,kDAAkD;QAClD,MAAMI,YAAYd,eAAeO,IAAI,CAClCQ,MAAM,CAAC,CAACC,WAAa,CAACtE,MAAMuE,kBAAkB,CAACD,aAAaZ,WAAWc,GAAG,CAACF,SAASG,GAAG,GACvFC,GAAG,CAAC,CAACJ;gBAEyBZ;mBAFX;gBAClBY;gBACAK,QAAQ3E,MAAM4E,cAAc,EAAClB,kBAAAA,WAAWO,GAAG,CAACK,SAASG,GAAG,aAA3Bf,kBAAgC,EAAE,EAAEY;YACnE;;QACF,MAAMO,QAAQ,IAAIC,IAAIxB,eAAeO,IAAI,CAACa,GAAG,CAAC,CAACJ,WAAaA,SAASG,GAAG;QACxE,MAAMM,UAAU1B,WAAWQ,IAAI,CAACQ,MAAM,CACpC,CAACT;gBAAoBA;mBAAX,CAACiB,MAAML,GAAG,EAACZ,mBAAAA,KAAKG,UAAU,YAAfH,mBAAmB;;QAE1C,MAAMoB,YAAY,CAACpB;;gBACjB5D;4BAAAA,uBAAAA,MAAMiF,aAAa,CACjB3B,eAAeO,IAAI,CAACqB,IAAI,CAAC,CAACZ,WAAaA,SAASG,GAAG,KAAKb,KAAKG,UAAU,GACvEH,KAAKuB,OAAO,sBAFdnF,qBAGGoF,IAAI,mBAAIxB,KAAKuB,OAAO;;QACzB,MAAME,MAAM;eAAIhC,WAAWQ,IAAI;SAAC,CAC7ByB,IAAI,CACH,CAACC,GAAGC;gBAAaA,gBAA6BD;mBAApCrC,QAAOsC,iBAAAA,EAAEzC,WAAW,YAAbyC,iBAAiB,KAAKtC,QAAOqC,iBAAAA,EAAExC,WAAW,YAAbwC,iBAAiB;WAEhEE,KAAK,CAAC,GAAGrD;QACZ,OAAO;YACLgC;YACAsB,cAAc1F,MAAM4E,cAAc,CAACG,SAAS;YAC5CY,eAAevB,UAAUwB,MAAM,CAC7B,CAACC,OAAOC,QAAUD,QAAQC,MAAMnB,MAAM,CAACgB,aAAa,EACpD;YAEFN;YACAL;YACAe,UAAU/F,MAAMgG,eAAe,CAAC3C,WAAWQ,IAAI;QACjD;IACF,GAAG;QAACR;QAAYC;KAAe;IAC/B,qEAAqE;IACrE,MAAM2C,QAAQ7E,QAAQ,IAAMpB,MAAMkG,UAAU,CAAC7C,WAAWQ,IAAI,GAAG;QAACR,WAAWQ,IAAI;KAAC;IAEhF,MAAMkC,WAAWtC,QAAQsC,QAAQ,CAACA,QAAQ;IAC1C,MAAMI,YAAY5C,gBAAgB;IAElC,qBACE,KAACpD;QACCiG,QAAQ;QACRC,MAAMrG,MAAMsG,cAAc,CAAC,cAAc;YACvCC,QAAQ;YACRC,SACE,mEACA,mEACA;QACJ;QACAC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAACnG;YAAMoG,SAAS;;8BACd,MAACpG;oBAAMqG,WAAU;oBAAMD,SAAS;oBAAGE,IAAI;wBAAEC,UAAU;oBAAO;;sCACxD,KAAC/E;4BACCgF,OAAO;4BACPC,OAAOlE,KAAKkE,KAAK,GAAGlE,KAAKkE,KAAK,CAACnG,KAAK,CAACoG,cAAc,KAAK;4BACxDC,MAAM;4BACNC,MAAMxE,OAAOyE,OAAO,CAAC;;sCAEvB,KAACrF;4BACCgF,OAAO;4BACPC,OAAOlE,KAAKkE,KAAK,GAAG/G,MAAM6C,KAAKkE,KAAK,CAACjE,WAAW,EAAEgD,YAAY;4BAC9DmB,MAAM;4BACNC,MAAMxE,OAAOyE,OAAO,CAAC;;sCAEvB,KAACrF;4BACCgF,OAAO;4BACPC,OAAOb,YAAYlG,MAAMwD,QAAQkC,aAAa,EAAEI,YAAY;4BAC5DmB,MACE7D,WAAWgE,SAAS,GAChB,CAAC,SAAS,EAAEnF,kBAAkB+E,cAAc,GAAG,sBAAsB,CAAC,GACtE;;sCAUR,KAAClF;4BACCgF,OAAO;4BACPC,OAAOb,YAAYF,MAAMpF,KAAK,CAACoG,cAAc,KAAK;4BAClDC,MAAM;4BACNI,YAAY;4BACZC,OAAOtB,MAAMpF,KAAK,GAAG,IAAI,iBAAiB2G;4BAC1CL,MAAMxE,OAAOyE,OAAO,CAAC;;;;gBAGxBtE,KAAKU,MAAM,KAAK,wBACf,KAAClD;oBAAMmH,UAAS;8BACb;qBAED;gBACHhE,QAAQsC,QAAQ,CAAC2B,KAAK,iBACrB,KAAC9G;oBAAW+G,SAAQ;oBAAUJ,OAAM;8BACjC,CAAC,6EAA6E,EAAExB,SAAS6B,WAAW,GAAG,kBAAkB,CAAC;qBAE3H;gBACHzB,aAAa,CAAC1C,QAAQW,SAAS,CAACyD,MAAM,IAAI,CAACxE,WAAWQ,IAAI,CAACgE,MAAM,iBAChE,KAACjH;oBAAW+G,SAAQ;oBAAQJ,OAAM;8BAC/B;qBAED;gBACH9D,QAAQW,SAAS,CAACM,GAAG,CAAC,CAAC,EAAEJ,QAAQ,EAAEK,MAAM,EAAE,iBAC1C,KAACvE;wBAA2B0H,OAAOxD,SAASc,IAAI,IAAI;kCAClD,cAAA,KAAC7D;4BACCsC,MAAM;mCACDc,OAAOoD,MAAM,CAACrD,GAAG,CAAC,CAACsD,MAAS,CAAA;wCAC7BlE,KAAKkE,IAAIC,KAAK,CAACC,EAAE;wCACjBnB,OAAOiB,IAAIC,KAAK,CAAC7C,IAAI;wCACrB4B,OAAOgB,IAAIjF,WAAW;wCACtBoF,SAASlI,MAAM+H,IAAIjF,WAAW,EAAEgD;wCAChCmB,MAAM,GAAGzF,OAAOuG,IAAInH,KAAK,EAAE,QAAQ,GAAG,EAAEZ,MAAM+H,IAAIrC,aAAa,EAAEI,UAAU,SAAS,CAAC;oCACvF,CAAA;mCACIpB,OAAOyD,QAAQ,CAACvH,KAAK,GACrB;oCACE;wCACEiD,KAAK;wCACLiD,OAAO;wCACPC,OAAOrC,OAAOyD,QAAQ,CAACrF,WAAW;wCAClCoF,SAASlI,MAAM0E,OAAOyD,QAAQ,CAACrF,WAAW,EAAEgD;wCAC5CmB,MAAM,GAAGzF,OAAOkD,OAAOyD,QAAQ,CAACvH,KAAK,EAAE,QAAQ,eAAe,CAAC;wCAC/D0G,OAAO;oCACT;iCACD,GACD,EAAE;6BACP;4BACDc,WAAW;;uBAvBD/D,SAASG,GAAG;gBA2B3BhB,QAAQiC,YAAY,CAAC7E,KAAK,iBACzB,KAACT;oBAAQ0H,OAAO;8BACd,cAAA,KAACvG;wBACCsC,MAAM;4BACJ;gCACEC,KAAK;gCACLiD,OAAO;gCACPC,OAAOvD,QAAQiC,YAAY,CAAC3C,WAAW;gCACvCoF,SAASlI,MAAMwD,QAAQiC,YAAY,CAAC3C,WAAW,EAAEgD;gCACjDmB,MAAM,GAAGzF,OAAOgC,QAAQiC,YAAY,CAAC7E,KAAK,EAAE,QAAQ,eAAe,CAAC;gCACpE0G,OAAO;4BACT;yBACD;wBACDc,WAAW;;qBAGb;gBACH/E,eAAe+D,SAAS,iBACvB,KAACzG;oBAAW+G,SAAQ;oBAAUJ,OAAM;8BACjC,CAAC,eAAe,EAAEpF,iBAAiB,uBAAuB,CAAC;qBAE5D;gBACHsB,QAAQ4B,GAAG,CAACwC,MAAM,iBACjB,MAACzH;oBAAQ0H,OAAO;;sCACd,MAACzH;4BAAYiI,MAAK;;8CAChB,KAAC5H;8CACC,cAAA,MAACC;;0DACC,KAACF;0DAAW;;0DACZ,KAACA;0DAAW;;0DACZ,KAACA;0DAAW;;0DACZ,KAACA;gDAAU8H,OAAM;0DAAS;;;;;8CAG9B,KAAC/H;8CACEiD,QAAQ4B,GAAG,CAACX,GAAG,CAAC,CAACd;4CAWHA;6DAVb,MAACjD;;8DACC,KAACF;8DACC,cAAA,KAACP;wDAAQiH,MAAMxE,OAAOiB,IAAI,CAACA,KAAKa,GAAG;kEAChCb,KAAKkE,KAAK,IAAIlE,KAAKa,GAAG;;;8DAG3B,KAAChE;8DAAWgD,QAAQuB,SAAS,CAACpB;;8DAC9B,KAACnD;8DAAWkB,UAAUiC,KAAK4E,iBAAiB;;8DAC5C,KAAC/H;oDAAU8H,OAAM;8DACdtI,MACCiD,QAAOU,oBAAAA,KAAKb,WAAW,YAAhBa,oBAAoB,IAC3B6E,OAAO7E,KAAKmC,QAAQ,IAAIA;;;2CAXfnC,KAAKa,GAAG;;;;;sCAkB7B,KAACjD;4BACCkH,UAAUhH,eAAe;4BACzBiH,SAAStG;4BACTwB,MAAM,IACJJ,QAAQ4B,GAAG,CAACX,GAAG,CAAC,CAACd;wCAMPA;2CANgB;wCACxBA,KAAKkE,KAAK,IAAIlE,KAAKa,GAAG;wCACtBhB,QAAQuB,SAAS,CAACpB;wCAClB,OAAOA,KAAK4E,iBAAiB,KAAK,YAAY5E,KAAK4E,iBAAiB,GAAG,IACnE,IAAII,KAAKhF,KAAK4E,iBAAiB,EAAEK,WAAW,GAAGpD,KAAK,CAAC,GAAG,MACxD;wCACHvC,CAAAA,QAAOU,oBAAAA,KAAKb,WAAW,YAAhBa,oBAAoB,KAAK,GAAE,EAAGkF,OAAO,CAAC;wCAC9CL,OAAO7E,KAAKmC,QAAQ,IAAIA,UAAU6B,WAAW;qCAC9C;;4BAEHmB,UAAU,CAAC5C;4BACX6C,SACE3F,WAAWgE,SAAS,GAChB,CAAC,kBAAkB,EAAEnF,kBAAkB+E,cAAc,GAAG,kCAAkC,CAAC,GAC3FO;;;qBAIR;;;;AAIZ;AACAlF,aAAa2G,WAAW,GAAG;AAE3B,eAAe3G,aAAY"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/pipeline-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 { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n count,\n getAggregateFromServer,\n limit,\n orderBy,\n query,\n sum,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename, shortDate } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many open deals the by-stage chart, the forecast and the top-deals\n * table are read from.\n *\n * The count and the face value of the pipeline are server aggregates and\n * never meet this bound. The forecast cannot be: it weights each deal by\n * ITS stage's odds, which is a join the server will not do, so it reads the\n * deals and says so when there were more than this.\n */\nconst OPEN_DEAL_CEILING = 1000\n/** More pipelines than this is a shape nobody has asked for; the probe says if it happens. */\nconst PIPELINE_CEILING = 20\nconst TOP_DEALS = 10\n\n/** The top-deals table's columns, which are also the CSV's — the amount split from its currency there. */\nconst TOP_DEAL_COLUMNS = ['Deal', 'Stage', 'Expected close', 'Amount', 'Currency'] as const\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface PipelineCardProps {\n report: CrmReportScope\n}\n\n/**\n * The open pipeline: how many deals, what they add up to, what they are\n * worth at their stage odds, where they sit, and the largest of them\n * (AGL-2604).\n *\n * Not period-scoped, deliberately. A pipeline is a stock, not a flow — what\n * is open NOW — and a \"pipeline over the last 30 days\" would be a report on\n * deals created in a window, which is a different question with a different\n * answer. The period picker governs the cards that measure flows.\n */\nexport function PipelineCard(props: PipelineCardProps) {\n const { report } = props\n const { scope, tokens, routes, nowMs } = report\n const firestore = useFirestore()\n\n const open = useAggregateRead(\n () =>\n getAggregateFromServer(\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n ),\n { count: count(), amountCents: sum('amountCents') },\n ).then((snapshot) => ({\n count: Number(snapshot.data().count ?? 0),\n amountCents: Number(snapshot.data().amountCents ?? 0),\n })),\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:open') },\n )\n\n /**\n * The open deals, most recently updated first — the order the\n * `(visibleTo, status, updatedAt)` index the deals list already uses can\n * serve, so the report adds no index for this read.\n */\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(\n scopedCollection(firestore, scope, 'pipelines'),\n ...visibleToClause(tokens),\n ),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n const dealsStatus = dealWindow.status\n\n const summary = useMemo(() => {\n const byPipeline = new Map<string, DealRow[]>()\n for (const deal of dealWindow.rows) {\n const key = deal.pipelineId ?? ''\n const list = byPipeline.get(key) ?? []\n list.push(deal)\n byPipeline.set(key, list)\n }\n // Every active pipeline is charted even with nothing open; an archived\n // one only while a deal still sits open in it — the forecast card's\n // rule, so the two cards name the same pipelines.\n const pipelines = pipelineWindow.rows\n .filter((pipeline) => !Aglyn.isPipelineArchived(pipeline) || byPipeline.has(pipeline.$id))\n .map((pipeline) => ({\n pipeline,\n totals: Aglyn.pipelineTotals(byPipeline.get(pipeline.$id) ?? [], pipeline),\n }))\n const known = new Set(pipelineWindow.rows.map((pipeline) => pipeline.$id))\n const orphans = dealWindow.rows.filter(\n (deal) => !known.has(deal.pipelineId ?? ''),\n )\n const stageName = (deal: DealRow): string =>\n Aglyn.dealStageById(\n pipelineWindow.rows.find((pipeline) => pipeline.$id === deal.pipelineId),\n deal.stageId,\n )?.name ?? deal.stageId\n const top = [...dealWindow.rows]\n .sort(\n (a, b) => Number(b.amountCents ?? 0) - Number(a.amountCents ?? 0),\n )\n .slice(0, TOP_DEALS)\n return {\n pipelines,\n orphanTotals: Aglyn.pipelineTotals(orphans, null),\n weightedCents: pipelines.reduce(\n (total, entry) => total + entry.totals.weightedCents,\n 0,\n ),\n top,\n stageName,\n currency: Aglyn.currencyOfDeals(dealWindow.rows),\n }\n }, [dealWindow, pipelineWindow])\n // Open deals with nothing scheduled (AGL-2661), off the same window.\n const stuck = useMemo(() => Aglyn.stuckDeals(dealWindow.rows), [dealWindow.rows])\n\n const currency = summary.currency.currency\n const dealsRead = dealsStatus === 'success'\n\n return (\n <CardDisplay\n header={'Pipeline'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#pipeline',\n excerpt:\n 'Every open deal: how many, what they add up to, and what they ' +\n 'are worth once each is weighted by the odds of its stage. Not ' +\n 'tied to the period — a pipeline is what is open now.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Open deals'}\n value={open.value ? open.value.count.toLocaleString() : null}\n note={'counted on the server'}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Pipeline value'}\n value={open.value ? money(open.value.amountCents, currency) : null}\n note={'face value of every open deal'}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Weighted forecast'}\n value={dealsRead ? money(summary.weightedCents, currency) : null}\n note={\n dealWindow.truncated\n ? `from the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated`\n : 'each deal at its stage odds'\n }\n />\n {/*\n Open deals with no open task against them (AGL-2661): the ones\n nobody has a next step for. Painted as a warning above zero,\n because a deal with nothing planned is a deal going nowhere; the\n tile opens the deals list, whose \"No next activity\" chip lists\n them.\n */}\n <ReportStatTile\n label={'Stuck deals'}\n value={dealsRead ? stuck.count.toLocaleString() : null}\n note={'open, with no next activity'}\n riseIsGood={false}\n color={stuck.count > 0 ? 'warning.main' : undefined}\n href={routes.section('deals')}\n />\n </Stack>\n {open.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The deal totals could not be read.'}\n </Alert>\n ) : null}\n {summary.currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the totals add them as numbers and show ${currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {dealsRead && !summary.pipelines.length && !dealWindow.rows.length ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : null}\n {summary.pipelines.map(({ pipeline, totals }) => (\n <Section key={pipeline.$id} title={pipeline.name || 'Pipeline'}>\n <ReportBreakdown\n rows={[\n ...totals.stages.map((row) => ({\n key: row.stage.id,\n label: row.stage.name,\n value: row.amountCents,\n display: money(row.amountCents, currency),\n note: `${plural(row.count, 'deal')} · ${money(row.weightedCents, currency)} weighted`,\n })),\n ...(totals.unplaced.count\n ? [\n {\n key: '$unplaced',\n label: 'Stage no longer in this pipeline',\n value: totals.unplaced.amountCents,\n display: money(totals.unplaced.amountCents, currency),\n note: `${plural(totals.unplaced.count, 'deal')} · not forecast`,\n color: 'warning.main',\n },\n ]\n : []),\n ]}\n emptyText={'This pipeline has no open stages.'}\n />\n </Section>\n ))}\n {summary.orphanTotals.count ? (\n <Section title={'Deals in no pipeline'}>\n <ReportBreakdown\n rows={[\n {\n key: '$orphans',\n label: 'No pipeline',\n value: summary.orphanTotals.amountCents,\n display: money(summary.orphanTotals.amountCents, currency),\n note: `${plural(summary.orphanTotals.count, 'deal')} · not forecast`,\n color: 'warning.main',\n },\n ]}\n emptyText={''}\n />\n </Section>\n ) : null}\n {pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are charted.`}\n </Typography>\n ) : null}\n {summary.top.length ? (\n <Section title={'Top open deals'}>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Deal'}</TableCell>\n <TableCell>{'Stage'}</TableCell>\n <TableCell>{'Expected close'}</TableCell>\n <TableCell align=\"right\">{'Amount'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {summary.top.map((deal) => (\n <TableRow key={deal.$id}>\n <TableCell>\n <AppLink href={routes.deal(deal.$id)}>\n {deal.title || deal.$id}\n </AppLink>\n </TableCell>\n <TableCell>{summary.stageName(deal)}</TableCell>\n <TableCell>{shortDate(deal.expectedCloseAtMs)}</TableCell>\n <TableCell align=\"right\">\n {money(\n Number(deal.amountCents ?? 0),\n String(deal.currency || currency),\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <ReportExport\n filename={reportFilename('top-open-deals')}\n columns={TOP_DEAL_COLUMNS}\n rows={() =>\n summary.top.map((deal) => [\n deal.title || deal.$id,\n summary.stageName(deal),\n typeof deal.expectedCloseAtMs === 'number' && deal.expectedCloseAtMs > 0\n ? new Date(deal.expectedCloseAtMs).toISOString().slice(0, 10)\n : '',\n (Number(deal.amountCents ?? 0) / 100).toFixed(2),\n String(deal.currency || currency).toUpperCase(),\n ])\n }\n disabled={!dealsRead}\n caption={\n dealWindow.truncated\n ? `Ranked within the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`\n : undefined\n }\n />\n </Section>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nPipelineCard.displayName = 'PipelineCard'\n\nexport default PipelineCard\n"],"names":["Aglyn","money","AppLink","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","count","getAggregateFromServer","limit","orderBy","query","sum","where","useMemo","useFirestore","collectionCeiling","ReportBreakdown","ReportExport","plural","reportFilename","shortDate","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","OPEN_DEAL_CEILING","PIPELINE_CEILING","TOP_DEALS","TOP_DEAL_COLUMNS","PipelineCard","props","report","scope","tokens","routes","nowMs","firestore","open","amountCents","then","snapshot","Number","data","cacheKey","dealWindow","pipelineWindow","dealsStatus","status","summary","byPipeline","Map","deal","rows","key","pipelineId","list","get","push","set","pipelines","filter","pipeline","isPipelineArchived","has","$id","map","totals","pipelineTotals","known","Set","orphans","stageName","dealStageById","find","stageId","name","top","sort","a","b","slice","orphanTotals","weightedCents","reduce","total","entry","currency","currencyOfDeals","stuck","stuckDeals","dealsRead","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","value","toLocaleString","note","href","section","truncated","riseIsGood","color","undefined","severity","mixed","variant","toUpperCase","length","title","stages","row","stage","id","display","unplaced","emptyText","size","align","expectedCloseAtMs","String","filename","columns","Date","toISOString","toFixed","disabled","caption","displayName"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  import * as Aglyn from "@aglyn/aglyn";
19
- import { money } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
19
+ import { money } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
20
20
  import { CardDisplay } from "@aglyn/shared-ui-jsx";
21
21
  import { percent } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
22
22
  import { Alert, Stack, Typography } from "@mui/material";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/won-lost-by-owner-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 { money } from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { percent } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportBreakdown } from './report-breakdown'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\n\n/**\n * The same ceiling the Won and lost card reads its chart under, spelled\n * from one constant so the two cards share the read rather than each\n * paying for its own.\n */\nconst CLOSED_DEAL_CEILING = 500\n\n/** How many owners the breakdown draws before folding the rest into one row. */\nconst OWNER_ROW_CEILING = 8\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface WonLostByOwnerCardProps {\n report: CrmReportScope\n}\n\n/**\n * Who closed what (AGL-2662): the period's wins and losses, by the person\n * who owned the deal at the close.\n *\n * ## It costs no read\n *\n * The two windows are the SAME queries the Won and lost card builds, under\n * the same cache keys — `closed:won` and `closed:lost` — so\n * `useAggregateRead` either answers this card from the remembered window or\n * joins the other card's in-flight promise. Two cards, one pair of reads.\n * A key of its own here would have doubled the most expensive read on the\n * page to ask a different question of the same rows.\n *\n * ## Why the rows can under-report a very busy period\n *\n * The window is bounded, and the bound is on the deals rather than on the\n * owners — so a period with more than {@link CLOSED_DEAL_CEILING} closes of\n * an outcome is tallied over the most recent of them, and the caption says\n * so. A server-side group-by would need one aggregate per owner, and the\n * owner list is not known before the read.\n */\nexport function WonLostByOwnerCard(props: WonLostByOwnerCardProps) {\n const { report } = props\n const { scope, tokens, range } = report\n const firestore = useFirestore()\n // The roster resolves a uid into the name the team reads; a uid it no\n // longer carries — somebody who has left — is named by its own id rather\n // than dropped, because their closed deals still happened.\n const roster = useOrgMemberOptions(scope[1])\n\n const closedInPeriod = (status: Aglyn.CrmDealStatus) =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', status),\n where('closedAtMs', '>=', range.from),\n where('closedAtMs', '<', range.to),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () => closedInPeriod('won'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () => closedInPeriod('lost'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n\n const owners = useMemo(\n () => Aglyn.wonLostByOwner(wonWindow.rows, lostWindow.rows),\n [wonWindow.rows, lostWindow.rows],\n )\n const currency = useMemo(\n () => Aglyn.currencyOfDeals(wonWindow.rows).currency,\n [wonWindow.rows],\n )\n\n const read = wonWindow.status === 'success' && lostWindow.status === 'success'\n const failed = wonWindow.status === 'error' || lostWindow.status === 'error'\n\n const rows = useMemo(() => {\n const named = (uid: string) =>\n uid\n ? (Aglyn.findOrgMember(roster.options, uid)?.label ?? uid)\n : 'Unassigned'\n const shown = owners.slice(0, OWNER_ROW_CEILING)\n const folded = owners.slice(OWNER_ROW_CEILING)\n const rest = folded.reduce(\n (total, owner) => ({\n won: total.won + owner.won,\n lost: total.lost + owner.lost,\n wonAmountCents: total.wonAmountCents + owner.wonAmountCents,\n }),\n { won: 0, lost: 0, wonAmountCents: 0 },\n )\n return [\n ...shown.map((owner) => ({\n key: owner.uid || 'unassigned',\n label: named(owner.uid),\n value: owner.won,\n display: `${owner.won.toLocaleString()} won`,\n note:\n `${owner.lost.toLocaleString()} lost · ` +\n `${owner.winRate === null ? 'no rate' : `${percent(owner.winRate)} win rate`} · ` +\n `${money(owner.wonAmountCents, currency)}`,\n color: 'success.main',\n })),\n ...(folded.length\n ? [\n {\n key: '$rest',\n label: `${folded.length} more`,\n value: rest.won,\n display: `${rest.won.toLocaleString()} won`,\n note: `${rest.lost.toLocaleString()} lost · ${money(rest.wonAmountCents, currency)}`,\n color: 'success.main',\n },\n ]\n : []),\n ]\n }, [owners, roster.options, currency])\n\n return (\n <CardDisplay\n header={'Won and lost by owner'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost-by-owner',\n excerpt:\n 'Closed deals grouped by the person who owned them at the close, ' +\n 'ranked by how much closed rather than by win rate, with the deals ' +\n 'nobody owned kept as a row of their own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Alert severity=\"warning\">\n {'The closed deals could not be read, so this is not an empty period.'}\n </Alert>\n ) : null}\n <ReportBreakdown\n rows={rows}\n emptyText={read ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Counted over the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; this period held more.`}\n </Typography>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to whoever owned the deal when it closed, not to whoever created it.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nWonLostByOwnerCard.displayName = 'WonLostByOwnerCard'\n\nexport default WonLostByOwnerCard\n"],"names":["Aglyn","money","CardDisplay","percent","Alert","Stack","Typography","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportBreakdown","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","CLOSED_DEAL_CEILING","OWNER_ROW_CEILING","WonLostByOwnerCard","props","report","scope","tokens","range","firestore","roster","closedInPeriod","status","from","to","wonWindow","cacheKey","lostWindow","owners","wonLostByOwner","rows","currency","currencyOfDeals","read","failed","named","uid","findOrgMember","options","label","shown","slice","folded","rest","reduce","total","owner","won","lost","wonAmountCents","map","key","value","display","toLocaleString","note","winRate","color","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","emptyText","truncated","variant","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/won-lost-by-owner-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 { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { percent } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportBreakdown } from './report-breakdown'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\n\n/**\n * The same ceiling the Won and lost card reads its chart under, spelled\n * from one constant so the two cards share the read rather than each\n * paying for its own.\n */\nconst CLOSED_DEAL_CEILING = 500\n\n/** How many owners the breakdown draws before folding the rest into one row. */\nconst OWNER_ROW_CEILING = 8\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface WonLostByOwnerCardProps {\n report: CrmReportScope\n}\n\n/**\n * Who closed what (AGL-2662): the period's wins and losses, by the person\n * who owned the deal at the close.\n *\n * ## It costs no read\n *\n * The two windows are the SAME queries the Won and lost card builds, under\n * the same cache keys — `closed:won` and `closed:lost` — so\n * `useAggregateRead` either answers this card from the remembered window or\n * joins the other card's in-flight promise. Two cards, one pair of reads.\n * A key of its own here would have doubled the most expensive read on the\n * page to ask a different question of the same rows.\n *\n * ## Why the rows can under-report a very busy period\n *\n * The window is bounded, and the bound is on the deals rather than on the\n * owners — so a period with more than {@link CLOSED_DEAL_CEILING} closes of\n * an outcome is tallied over the most recent of them, and the caption says\n * so. A server-side group-by would need one aggregate per owner, and the\n * owner list is not known before the read.\n */\nexport function WonLostByOwnerCard(props: WonLostByOwnerCardProps) {\n const { report } = props\n const { scope, tokens, range } = report\n const firestore = useFirestore()\n // The roster resolves a uid into the name the team reads; a uid it no\n // longer carries — somebody who has left — is named by its own id rather\n // than dropped, because their closed deals still happened.\n const roster = useOrgMemberOptions(scope[1])\n\n const closedInPeriod = (status: Aglyn.CrmDealStatus) =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', status),\n where('closedAtMs', '>=', range.from),\n where('closedAtMs', '<', range.to),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () => closedInPeriod('won'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () => closedInPeriod('lost'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n\n const owners = useMemo(\n () => Aglyn.wonLostByOwner(wonWindow.rows, lostWindow.rows),\n [wonWindow.rows, lostWindow.rows],\n )\n const currency = useMemo(\n () => Aglyn.currencyOfDeals(wonWindow.rows).currency,\n [wonWindow.rows],\n )\n\n const read = wonWindow.status === 'success' && lostWindow.status === 'success'\n const failed = wonWindow.status === 'error' || lostWindow.status === 'error'\n\n const rows = useMemo(() => {\n const named = (uid: string) =>\n uid\n ? (Aglyn.findOrgMember(roster.options, uid)?.label ?? uid)\n : 'Unassigned'\n const shown = owners.slice(0, OWNER_ROW_CEILING)\n const folded = owners.slice(OWNER_ROW_CEILING)\n const rest = folded.reduce(\n (total, owner) => ({\n won: total.won + owner.won,\n lost: total.lost + owner.lost,\n wonAmountCents: total.wonAmountCents + owner.wonAmountCents,\n }),\n { won: 0, lost: 0, wonAmountCents: 0 },\n )\n return [\n ...shown.map((owner) => ({\n key: owner.uid || 'unassigned',\n label: named(owner.uid),\n value: owner.won,\n display: `${owner.won.toLocaleString()} won`,\n note:\n `${owner.lost.toLocaleString()} lost · ` +\n `${owner.winRate === null ? 'no rate' : `${percent(owner.winRate)} win rate`} · ` +\n `${money(owner.wonAmountCents, currency)}`,\n color: 'success.main',\n })),\n ...(folded.length\n ? [\n {\n key: '$rest',\n label: `${folded.length} more`,\n value: rest.won,\n display: `${rest.won.toLocaleString()} won`,\n note: `${rest.lost.toLocaleString()} lost · ${money(rest.wonAmountCents, currency)}`,\n color: 'success.main',\n },\n ]\n : []),\n ]\n }, [owners, roster.options, currency])\n\n return (\n <CardDisplay\n header={'Won and lost by owner'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost-by-owner',\n excerpt:\n 'Closed deals grouped by the person who owned them at the close, ' +\n 'ranked by how much closed rather than by win rate, with the deals ' +\n 'nobody owned kept as a row of their own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Alert severity=\"warning\">\n {'The closed deals could not be read, so this is not an empty period.'}\n </Alert>\n ) : null}\n <ReportBreakdown\n rows={rows}\n emptyText={read ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Counted over the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; this period held more.`}\n </Typography>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to whoever owned the deal when it closed, not to whoever created it.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nWonLostByOwnerCard.displayName = 'WonLostByOwnerCard'\n\nexport default WonLostByOwnerCard\n"],"names":["Aglyn","money","CardDisplay","percent","Alert","Stack","Typography","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportBreakdown","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","CLOSED_DEAL_CEILING","OWNER_ROW_CEILING","WonLostByOwnerCard","props","report","scope","tokens","range","firestore","roster","closedInPeriod","status","from","to","wonWindow","cacheKey","lostWindow","owners","wonLostByOwner","rows","currency","currencyOfDeals","read","failed","named","uid","findOrgMember","options","label","shown","slice","folded","rest","reduce","total","owner","won","lost","wonAmountCents","map","key","value","display","toLocaleString","note","winRate","color","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","emptyText","truncated","variant","displayName"],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginContactCaptureWriter } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture';
18
+ /**
19
+ * The CRM as the plugin that keeps people.
20
+ *
21
+ * Light by construction, the way the workflows listener is: the capture
22
+ * itself is imported when the first one arrives, not when the process
23
+ * starts, so a process that never captures anybody pays for this object and
24
+ * nothing else. That matters because this registers in EVERY server process,
25
+ * including ones that will never touch the CRM.
26
+ */
27
+ export declare const crmContactCaptureWriter: PluginContactCaptureWriter;
28
+ /**
29
+ * The plugin's SERVER declarations: what the server must know at boot, before
30
+ * any door of the CRM has been called.
31
+ *
32
+ * ## Why a declaration and not a `tenantApi` registration
33
+ *
34
+ * The doors that meet a person mostly never load this plugin. A form
35
+ * submission is a core route; an order and a booking are captured inside
36
+ * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry
37
+ * filled by a surface that did not load answers `null` — which
38
+ * `capturePluginContact` defines as "this workspace has no record system", a
39
+ * sentence that would be false and silent. Orders would stop creating
40
+ * contacts with nothing red anywhere, which is the AGL-3025 shape.
41
+ *
42
+ * Running from both apps' generated server-declarations manifest at boot is
43
+ * what makes the answer true in every process. It is also called from the
44
+ * plugin's own API register functions, so a process whose boot did not run it
45
+ * still registers the writer the first time a CRM door loads. Registering
46
+ * twice replaces in place.
47
+ *
48
+ * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A
49
+ * second plugin's is refused naming both, and the incumbent keeps serving.
50
+ */
51
+ export declare function registerCrmServerDeclarations(): void;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ // The contract from its own module, not the plugin-manager barrel: boot needs
17
+ // the registry and nothing else, and the barrel reaches the client contexts.
18
+ import { registerPluginContactCaptureWriter } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
19
+ import { BUNDLE_ID } from "./constants/bundle-common.js";
20
+ /**
21
+ * The CRM as the plugin that keeps people.
22
+ *
23
+ * Light by construction, the way the workflows listener is: the capture
24
+ * itself is imported when the first one arrives, not when the process
25
+ * starts, so a process that never captures anybody pays for this object and
26
+ * nothing else. That matters because this registers in EVERY server process,
27
+ * including ones that will never touch the CRM.
28
+ */ export const crmContactCaptureWriter = {
29
+ async capture (request) {
30
+ const { captureContactForCrm } = await import("./server/capture-contact.js");
31
+ return await captureContactForCrm(request);
32
+ }
33
+ };
34
+ /**
35
+ * The plugin's SERVER declarations: what the server must know at boot, before
36
+ * any door of the CRM has been called.
37
+ *
38
+ * ## Why a declaration and not a `tenantApi` registration
39
+ *
40
+ * The doors that meet a person mostly never load this plugin. A form
41
+ * submission is a core route; an order and a booking are captured inside
42
+ * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry
43
+ * filled by a surface that did not load answers `null` — which
44
+ * `capturePluginContact` defines as "this workspace has no record system", a
45
+ * sentence that would be false and silent. Orders would stop creating
46
+ * contacts with nothing red anywhere, which is the AGL-3025 shape.
47
+ *
48
+ * Running from both apps' generated server-declarations manifest at boot is
49
+ * what makes the answer true in every process. It is also called from the
50
+ * plugin's own API register functions, so a process whose boot did not run it
51
+ * still registers the writer the first time a CRM door loads. Registering
52
+ * twice replaces in place.
53
+ *
54
+ * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A
55
+ * second plugin's is refused naming both, and the incumbent keeps serving.
56
+ */ export function registerCrmServerDeclarations() {
57
+ registerPluginContactCaptureWriter(crmContactCaptureWriter, {
58
+ pluginId: BUNDLE_ID
59
+ });
60
+ }
61
+
62
+ //# sourceMappingURL=declarations.server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.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// The contract from its own module, not the plugin-manager barrel: boot needs\n// the registry and nothing else, and the barrel reaches the client contexts.\nimport {\n registerPluginContactCaptureWriter,\n type PluginContactCaptureWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The CRM as the plugin that keeps people.\n *\n * Light by construction, the way the workflows listener is: the capture\n * itself is imported when the first one arrives, not when the process\n * starts, so a process that never captures anybody pays for this object and\n * nothing else. That matters because this registers in EVERY server process,\n * including ones that will never touch the CRM.\n */\nexport const crmContactCaptureWriter: PluginContactCaptureWriter = {\n async capture(request) {\n const { captureContactForCrm } = await import('./server/capture-contact')\n return await captureContactForCrm(request)\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot, before\n * any door of the CRM has been called.\n *\n * ## Why a declaration and not a `tenantApi` registration\n *\n * The doors that meet a person mostly never load this plugin. A form\n * submission is a core route; an order and a booking are captured inside\n * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry\n * filled by a surface that did not load answers `null` — which\n * `capturePluginContact` defines as \"this workspace has no record system\", a\n * sentence that would be false and silent. Orders would stop creating\n * contacts with nothing red anywhere, which is the AGL-3025 shape.\n *\n * Running from both apps' generated server-declarations manifest at boot is\n * what makes the answer true in every process. It is also called from the\n * plugin's own API register functions, so a process whose boot did not run it\n * still registers the writer the first time a CRM door loads. Registering\n * twice replaces in place.\n *\n * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A\n * second plugin's is refused naming both, and the incumbent keeps serving.\n */\nexport function registerCrmServerDeclarations(): void {\n registerPluginContactCaptureWriter(crmContactCaptureWriter, {\n pluginId: BUNDLE_ID,\n })\n}\n"],"names":["registerPluginContactCaptureWriter","BUNDLE_ID","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SACEA,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;;;;CAQC,GACD,OAAO,MAAMC,0BAAsD;IACjE,MAAMC,SAAQC,OAAO;QACnB,MAAM,EAAEC,oBAAoB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC9C,OAAO,MAAMA,qBAAqBD;IACpC;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASE;IACdN,mCAAmCE,yBAAyB;QAC1DK,UAAUN;IACZ;AACF"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginContactCaptureRequest, PluginContactCaptured } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture';
18
+ import { type ContactSource } from '@aglyn/aglyn/app-utils/contacts';
19
+ /**
20
+ * The CRM answering the platform's contact-capture contract (AGL-3080).
21
+ *
22
+ * Four silos meet the same person — a form submission, a member signing up,
23
+ * an order, a booking — and none of them is the record system. Each holds an
24
+ * address, a name and the fact that something happened, and each wants the
25
+ * workspace's person record to know. Today each imports `captureHostContact`
26
+ * directly, which is the highest fan-in edge in the repo and makes the CRM
27
+ * something a storefront cannot take a payment without.
28
+ *
29
+ * So this is the CRM's side of the seam: the silo reports what it saw, and
30
+ * the plugin that keeps people decides everything a record system decides —
31
+ * keying the address, whether this is somebody new, the audience band, the
32
+ * erasure rows, the company, the owner, and what a new person sets off.
33
+ * `captureHostContact` already does all of that; this translates the
34
+ * contract's vocabulary into its options and its verdict back.
35
+ *
36
+ * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is
37
+ * recording — the submission was accepted, the order was paid — so a refusal
38
+ * is RETURNED and costs the silo nothing. A throw here would lose an order
39
+ * for a contact that could not be filed.
40
+ */
41
+ export declare function captureContactForCrm(request: PluginContactCaptureRequest): Promise<PluginContactCaptured>;
42
+ /**
43
+ * The silo's word for its door, as a source the CRM stores.
44
+ *
45
+ * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open
46
+ * string, deliberately: a silo declares its door with
47
+ * `registerPluginContactSource` rather than core enumerating every plugin's.
48
+ * The two meet here, and a word outside the union is passed through rather
49
+ * than rejected — refusing it would lose a third-party plugin's capture over
50
+ * a label, and the capture is the part that matters.
51
+ *
52
+ * ⛔ What it costs, until the union is widened: the console's source filter
53
+ * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw
54
+ * and matches no filter. Every first-party silo uses a word in the union, so
55
+ * nothing does that today.
56
+ */
57
+ export declare function contactSourceOf(source: string): ContactSource;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
19
+ /**
20
+ * The CRM answering the platform's contact-capture contract (AGL-3080).
21
+ *
22
+ * Four silos meet the same person — a form submission, a member signing up,
23
+ * an order, a booking — and none of them is the record system. Each holds an
24
+ * address, a name and the fact that something happened, and each wants the
25
+ * workspace's person record to know. Today each imports `captureHostContact`
26
+ * directly, which is the highest fan-in edge in the repo and makes the CRM
27
+ * something a storefront cannot take a payment without.
28
+ *
29
+ * So this is the CRM's side of the seam: the silo reports what it saw, and
30
+ * the plugin that keeps people decides everything a record system decides —
31
+ * keying the address, whether this is somebody new, the audience band, the
32
+ * erasure rows, the company, the owner, and what a new person sets off.
33
+ * `captureHostContact` already does all of that; this translates the
34
+ * contract's vocabulary into its options and its verdict back.
35
+ *
36
+ * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is
37
+ * recording — the submission was accepted, the order was paid — so a refusal
38
+ * is RETURNED and costs the silo nothing. A throw here would lose an order
39
+ * for a contact that could not be filed.
40
+ */ export async function captureContactForCrm(request) {
41
+ try {
42
+ var _request_tags, _request_campaignIds;
43
+ const verdict = await captureHostContact(_extends({
44
+ hostId: request.hostId,
45
+ email: request.identity.email
46
+ }, request.identity.name ? {
47
+ name: request.identity.name
48
+ } : {}, {
49
+ source: contactSourceOf(request.interaction.source),
50
+ interaction: _extends({}, request.interaction.atMs === undefined ? {} : {
51
+ atMs: request.interaction.atMs
52
+ }, request.interaction.refId ? {
53
+ refId: request.interaction.refId
54
+ } : {}, request.interaction.summary ? {
55
+ summary: request.interaction.summary
56
+ } : {}, entryPointOf(request.detail))
57
+ }, campaignTouchOf(request.detail), request.marketingConsent === undefined ? {} : {
58
+ marketingConsent: request.marketingConsent
59
+ }, ((_request_tags = request.tags) == null ? void 0 : _request_tags.length) ? {
60
+ tags: request.tags
61
+ } : {}, ((_request_campaignIds = request.campaignIds) == null ? void 0 : _request_campaignIds.length) ? {
62
+ campaignIds: request.campaignIds
63
+ } : {}, request.purchaseCents === undefined ? {} : {
64
+ purchaseCents: request.purchaseCents
65
+ }, request.purchaseCurrency ? {
66
+ purchaseCurrency: request.purchaseCurrency
67
+ } : {}, request.lifecycleFloor ? {
68
+ initialLifecycleStage: request.lifecycleFloor
69
+ } : {}, request.profile ? {
70
+ facet: request.profile
71
+ } : {}));
72
+ if ('refused' in verdict) {
73
+ return {
74
+ ok: false,
75
+ reason: verdict.refused,
76
+ error: refusalText(verdict.refused)
77
+ };
78
+ }
79
+ return {
80
+ ok: true,
81
+ contactId: verdict.contactId,
82
+ created: verdict.created
83
+ };
84
+ } catch (error) {
85
+ /*
86
+ * The contract's `error` is the last state, not a channel for a stack:
87
+ * a silo may show or log it as it stands, so it says what happened and
88
+ * names nothing internal. The detail goes to the log, where whoever is
89
+ * debugging a missing contact will look.
90
+ */ console.error('crm contact capture failed', error);
91
+ return {
92
+ ok: false,
93
+ reason: 'error',
94
+ error: 'The contact could not be recorded. Nothing else was affected.'
95
+ };
96
+ }
97
+ }
98
+ /**
99
+ * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).
100
+ *
101
+ * The contract carries a capture's silo-side facts opaquely, so the two the
102
+ * CRM models are picked out here rather than typed into the platform. Both
103
+ * ride the INTERACTION, which is where `upsertHostContact` already keeps
104
+ * them: which form a person came in through routes the owner-assignment
105
+ * rules and rides the `contactCreated` payload, and the page is what a
106
+ * timeline row says about where they were.
107
+ *
108
+ * Absent, misspelled or the wrong type is the same answer as a door that
109
+ * never had one — a capture without an entry point, which is every capture
110
+ * that did not come through a form. It is never a reason to refuse: the
111
+ * person is the part that matters.
112
+ */ function entryPointOf(detail) {
113
+ const text = (value)=>typeof value === 'string' && value.trim() ? value : undefined;
114
+ const formId = text(detail == null ? void 0 : detail['formId']);
115
+ const path = text(detail == null ? void 0 : detail['path']);
116
+ return _extends({}, formId ? {
117
+ formId
118
+ } : {}, path ? {
119
+ path
120
+ } : {});
121
+ }
122
+ /**
123
+ * WHERE THE VISITOR CAME FROM, off the same bag.
124
+ *
125
+ * ⚠️ A different fact from `campaignIds` and the two must never be folded
126
+ * together — `upsert-contact.ts` says so at the field itself. A touch is the
127
+ * ad or the link the visitor arrived by, already resolved through the
128
+ * allowlist by the silo; `campaignIds` is which campaigns the merchant filed
129
+ * the capture SURFACE under, which is true of everybody who fills that form
130
+ * in. Folding them would credit a campaign for a visitor who typed the
131
+ * address.
132
+ *
133
+ * Passed through as the silo resolved it, unread: the touch's shape belongs
134
+ * to whatever resolves it, and re-validating it here would be a second copy
135
+ * of a rule that has already run. Only its presence is decided here, because
136
+ * `null` and absent mean the same thing to the writer and a caller should
137
+ * not have to know which one it sends.
138
+ */ function campaignTouchOf(detail) {
139
+ const touch = detail == null ? void 0 : detail['campaignTouch'];
140
+ return touch ? {
141
+ campaignTouch: touch
142
+ } : {};
143
+ }
144
+ /**
145
+ * The silo's word for its door, as a source the CRM stores.
146
+ *
147
+ * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open
148
+ * string, deliberately: a silo declares its door with
149
+ * `registerPluginContactSource` rather than core enumerating every plugin's.
150
+ * The two meet here, and a word outside the union is passed through rather
151
+ * than rejected — refusing it would lose a third-party plugin's capture over
152
+ * a label, and the capture is the part that matters.
153
+ *
154
+ * ⛔ What it costs, until the union is widened: the console's source filter
155
+ * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw
156
+ * and matches no filter. Every first-party silo uses a word in the union, so
157
+ * nothing does that today.
158
+ */ export function contactSourceOf(source) {
159
+ const word = String(source != null ? source : '').trim();
160
+ if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {
161
+ // Said once, where somebody debugging an unlabelled row will find it.
162
+ // Not a refusal: the capture is worth more than the label.
163
+ console.warn(`crm contact capture: source "${word}" has no label, so it will render ` + 'raw and match no filter in the console.');
164
+ }
165
+ return word;
166
+ }
167
+ /** What a refused caller is told — customer-safe, and never a key. */ function refusalText(reason) {
168
+ switch(reason){
169
+ case 'invalid-email':
170
+ return 'That address could not be read, so no contact was recorded.';
171
+ case 'band':
172
+ return 'This workspace is at the number of contacts its plan holds.';
173
+ case 'erased':
174
+ return 'This person was erased from this workspace and was not recreated.';
175
+ default:
176
+ return 'The contact could not be recorded. Nothing else was affected.';
177
+ }
178
+ }
179
+
180
+ //# sourceMappingURL=capture-contact.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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 type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport type { UpsertHostContactOptions } from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return { ok: true, contactId: verdict.contactId, created: verdict.created }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","captureHostContact","captureContactForCrm","request","verdict","hostId","email","identity","name","source","contactSourceOf","interaction","atMs","undefined","refId","summary","entryPointOf","detail","campaignTouchOf","marketingConsent","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","ok","reason","refused","error","refusalText","contactId","created","console","text","value","trim","formId","path","touch","campaignTouch","word","String","Object","hasOwn","warn"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -54,6 +54,7 @@ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refus
54
54
  import { captureHostContact, emitHostEvent } from "@aglyn/tenant-runtime";
55
55
  import { FieldValue } from "firebase-admin/firestore";
56
56
  import { CRM_API_ROUTES } from "./constants/api-routes.js";
57
+ import { registerCrmServerDeclarations } from "./declarations.server.js";
57
58
  import { BUNDLE_ID } from "./constants/bundle-common.js";
58
59
  import { CRM_NEXT_ACTIVITY_ROUTE } from "./model/next-activity.js";
59
60
  import { CRM_TASK_ROUTES } from "./model/task-routes.js";
@@ -531,6 +532,12 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
531
532
  }
532
533
  };
533
534
  /** Console API registration, named in `plugins.config.json` as `consoleApi`. */ export function registerCrmConsoleApi() {
535
+ /*
536
+ * The contact-capture writer, again (AGL-3080). Both apps run it at boot
537
+ * from their generated server-declarations manifest, and this is the
538
+ * backstop for a process whose boot did not: the first CRM door to load
539
+ * registers it. Registering twice replaces in place.
540
+ */ registerCrmServerDeclarations();
534
541
  registerPluginApiRoute(CRM_API_ROUTES.ping, crmPingHandler);
535
542
  registerPluginApiRoute(CRM_API_ROUTES.contactStage, contactStageHandler);
536
543
  // Every other console write to a contact's facets (AGL-2804): the rules
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/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 Contacts plugin's SERVER half (AGL-2595).\n *\n * Contacts CRM v1 had none: every console write was client-direct against\n * the Firestore rules, and the one server path that creates a contact —\n * `upsertHostContact` — belongs to the capture doors, not to this plugin.\n * v2 needs a server for the things a browser must not do alone: an import\n * that dedupes forty thousand rows, a bulk action over a selection, an\n * auto-association of contacts to companies by domain. Each of those lands\n * as a `crm/<route>` handler registered here.\n *\n * `crm/ping` exists so the wiring is PROVEN rather than assumed:\n * `plugins.config.json` names this module's register function and the\n * `crm` API prefix, the generated server manifest loads this file, and the\n * console's `/api/[...pluginApi]` dispatcher reaches the handler. A plugin\n * whose first real route also had to be its first wiring test would have\n * two things to debug at once.\n *\n * The real routes are the writes with a side effect outside the document:\n * `crm/contact-stage` (AGL-2605) exists for the EVENT — `contactStageChanged`\n * can only be emitted by a server path that performed the write, so a record\n * page that moved the stage client-direct would move the person and tell no\n * automation; the task routes (AGL-2599) carry an assignee's notification and\n * the `taskCompleted` event; `crm/contacts-import` (AGL-2602) pushes one chunk\n * of a file through the same capture door every other server door uses.\n * `crm/deal-stage` (AGL-2598) is the one writer of a deal's stage, won and lost,\n * because a stage change is what automations listen for (`server-deal-stage.ts`).\n * `crm/contacts-create` (AGL-2596) is a person typed into the console by a\n * member of the team — a server route because the dedupe against every\n * holder's rows and the audience band are judgments the browser cannot make.\n * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:\n * the rules cannot tell one field of a holder's facet from another, so they\n * leave a client nothing there but letting a holder go, and the plan is\n * asked here about the fields a save carries.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n contactFacetPath,\n CRM_COLLECTIONS,\n crmReadTokens,\n isContactLifecycleStage,\n isOrgWideMember,\n normalizeAddress,\n normalizeContactEmail,\n normalizePhone,\n type AglynPostalAddress,\n type PluginApiHandler,\n type PluginApiRequest,\n readContactFacet,\n registerPluginApiRoute,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { captureHostContact, emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CRM_API_ROUTES } from './constants/api-routes'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { CRM_NEXT_ACTIVITY_ROUTE } from './model/next-activity'\nimport { CRM_TASK_ROUTES } from './model/task-routes'\nimport { crmNextActivityHandler } from './server/next-activity-routes'\nimport { registerCrmRecordTimelineWriter } from './server/record-timeline'\nimport { crmTaskCompleteHandler, crmTaskSaveHandler } from './server/task-routes'\nimport { crmCompaniesImportHandler } from './server/companies-import'\nimport { crmContactsImportHandler } from './server/contacts-import'\nimport { crmDealsImportHandler } from './server/deals-import'\nimport { crmLeadsImportHandler } from './server/leads-import'\nimport { crmTasksImportHandler } from './server/tasks-import'\nimport { crmDealStageHandler } from './server-deal-stage'\nimport { crmEmailSendHandler } from './server/email-send'\nimport { leadConvertHandler } from './server/lead-convert'\nimport {\n CONTACT_EMAIL_HISTORY_ROUTE,\n contactEmailHistoryHandler,\n} from './server/contact-email-history'\nimport { CONTACTS_MERGE_ROUTE, contactsMergeHandler } from './server/contacts-merge'\nimport { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from './server/erase-person'\nimport { CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler } from './server/org-activity'\nimport { CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler } from './server/inbound-address'\nimport { crmCompanyDeleteHandler } from './server/company-delete'\nimport { CONTACT_PHONE_REFUSAL, normalizeTags, typed } from './server/contact-profile'\nimport { crmContactUpdateHandler } from './server/contact-update'\nimport {\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n} from './server/email-template-duplicate'\nimport { registerCrmRecordFactsReaders } from './server/record-facts'\nimport { crmSuiteRefusal } from './server/suite-gate'\nimport {\n CRM_RECIPE_INSTALL_ROUTE,\n CRM_RECIPE_STATUS_ROUTE,\n crmRecipeInstallHandler,\n crmRecipeStatusHandler,\n} from './server/recipe-routes'\n\n/**\n * `GET /api/crm/ping` → `{ ok: true, plugin: 'crm' }`.\n *\n * No auth, no host, no data: it answers whether the plugin's server bundle\n * was loaded and its routes registered, which is a fact about the process\n * and not about any org. Anything that reads a document goes behind the\n * same session and role checks the other plugins' console routes use.\n */\nexport const crmPingHandler: PluginApiHandler = (req, res) => {\n if (req.method !== 'GET') {\n res.setHeader('Allow', 'GET')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n res.status(200).json({ ok: true, plugin: BUNDLE_ID })\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/**\n * Who is calling, and whether they may edit this site's CRM.\n *\n * The same check the inbox, bookings and email console routes make: a\n * bearer ID token, verified, and an `admin` or `editor` role on the host\n * document. A contact is a site's business record, so the site's own role\n * map is the right authority; org-wide reach is not required, because a\n * site editor who can open the record in the console can already write\n * every other field on it client-direct.\n */\nasync function authorizeSiteEditor(\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ ok: true; uid: string } | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let uid: string\n try {\n uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the site editor could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const hostSnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return { ok: false, status: 403, error: 'Not a site admin or editor' }\n }\n return { ok: true, uid }\n}\n\n/**\n * `POST /api/crm/contact-stage` with `{ hostId, contactId, lifecycleStage }`.\n *\n * Writes the stage into THIS site's facet on the contact — a dotted\n * `update()`, never a top-level field, because the row is shared by every\n * site in the org and a stage is one holder's reading of the person — and\n * then announces `contactStageChanged` with the stage it replaced.\n *\n * A stage set to what it already is writes nothing and announces nothing:\n * the response says `changed: false`, and no automation listening for a\n * change hears one that did not happen.\n *\n * `lifecycleStage: null` CLEARS the stage (AGL-2804). A facet is the\n * server's to write, so \"not placed yet\" comes through the route that owns\n * the stage rather than as a client-direct delete, and it is refused to a\n * plan without the suite the way a move is. There is no event for \"no\n * stage\", so a clear announces nothing; an empty string names no stage and\n * is refused like any other the list does not have.\n *\n * The contact is looked up by id and then checked against `visibleTo`,\n * because the Admin SDK evaluates no rules: a caller who can edit site A\n * must not be able to restage a contact only site B holds by guessing its\n * id, and the scoped query the list uses is not available to a `doc()` read.\n */\nexport const contactStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n const requested: unknown = req.body?.lifecycleStage\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n // `null` clears the stage; anything else has to be one of the list's.\n const clearing = requested === null\n const lifecycleStage = clearing\n ? ''\n : isContactLifecycleStage(requested)\n ? requested\n : null\n if (lifecycleStage === null) {\n res.status(400).json({ error: 'Pick a lifecycle stage' })\n return\n }\n const caller = await authorizeSiteEditor(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n try {\n // A lifecycle stage is the suite's (AGL-2787) — see `suite-gate.ts`.\n const owner = await getOrgForHost(hostId)\n if (!owner) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(owner.org, \"Moving a contact's lifecycle stage\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n if (!snapshot.exists || !visibleToHost(snapshot.get('visibleTo'), hostId)) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(\n snapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const previousStage = facet.lifecycleStage ?? ''\n if (previousStage === lifecycleStage) {\n res\n .status(200)\n .json({ ok: true, changed: false, lifecycleStage, previousStage })\n return\n }\n await snapshot.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: clearing\n ? FieldValue.delete()\n : lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Awaited, not floated: a serverless response ending cancels in-flight\n // work, and the event is the reason this route exists. A clear has none.\n if (!clearing) {\n await emitHostEvent(hostId, 'contactStageChanged', {\n contactId,\n email: String(snapshot.get('email') ?? ''),\n lifecycleStage,\n previousStage,\n })\n }\n res\n .status(200)\n .json({ ok: true, changed: true, lifecycleStage, previousStage })\n } catch (error) {\n console.error('[crm] contact-stage failed', hostId, contactId, error)\n res.status(500).json({ error: 'The stage could not be changed' })\n }\n}\n\n/**\n * What the create route says when the band refused (AGL-2596).\n *\n * The contacts list's own alert, in the past tense: the list says new\n * visitors are \"no longer captured\", and this says the one contact the\n * reader just tried to add was not. The remedy is the same sentence in both\n * places, so a reader who sees one and then the other is told one thing.\n */\nexport const CONTACT_BAND_FULL_MESSAGE =\n 'Contact limit reached — this contact was not added. Upgrade in Billing ' +\n 'to keep collecting.'\n\n/**\n * `POST /api/crm/contacts-create` — a person added by hand (AGL-2596).\n *\n * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,\n * companyId?, address?, ownerUid?, lifecycleStage?, tags?,\n * marketingConsent? }`. Answers `{ contactId, created }`: `created` is\n * false when the address already belonged to somebody, in which case what\n * was typed MERGES into the existing row — the dedupe the shared address\n * book exists for, and the reason a second \"create\" of one person is not\n * an error.\n *\n * ## Who may call it\n *\n * The caller's ID token, then `data.manage` on the site's org — the same key\n * the console surface itself is gated on, resolved through the same\n * three-layer permission read the members route uses. A scoped member is\n * admitted only for a site they reach: the rules would refuse a browser\n * write outside their tokens, and a server route that admitted it would be\n * a way round the rules rather than a service in front of them.\n *\n * ## What it refuses, and how\n *\n * A malformed email or phone number is a 400 with a sentence the drawer can\n * show under the field. The band is a 409 carrying the list's own wording:\n * it is not a bad request and not a server fault, it is the plan saying no,\n * and the drawer relays the sentence rather than inventing one.\n *\n * ## The company (AGL-2613)\n *\n * `companyId` is the picker's choice, and it is checked before anything is\n * written: the company has to exist and be visible to the capturing site's\n * scope, or a caller could file a person under a record they cannot open —\n * the same refusal the lead conversion makes. It reaches the upsert as\n * `facet.companyId`, which is where the link is kept in step with its mirror\n * and the company's contacts count; a merge onto somebody already at another\n * company is a MOVE there, not a second link. The stored company's own name\n * is what is echoed, over whatever the client sent, because the name on the\n * record is the company's and not the form's.\n *\n * ## What it writes beyond the upsert\n *\n * `upsertHostContact` takes the profile — phone, title, address, owner,\n * stage, company — and writes it into the capturing group's facet. The two\n * things it does not take are written here by DOTTED path afterwards: the\n * tags, which union into whatever the person already carried, and the\n * company name — the picked company's, or free text for a person filed\n * under a name no company record carries yet. Dotted paths because this is\n * an `update()`, and only an update reads a dot as a path — a nested object\n * here would replace the facet map and take every other holder's records\n * with it.\n */\nexport const crmContactsCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n const hostId = typed(body['hostId'], 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body['email'])\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n const rawPhone = typed(body['phone'], 40)\n const phone = rawPhone ? normalizePhone(rawPhone) : null\n if (rawPhone && !phone) {\n res.status(400).json({ error: CONTACT_PHONE_REFUSAL })\n return\n }\n const rawStage = typed(body['lifecycleStage'], 40)\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n res.status(400).json({ error: 'Unknown lifecycle stage.' })\n return\n }\n const name = typed(body['name'], 120)\n const jobTitle = typed(body['jobTitle'], 120)\n let companyName = typed(body['companyName'], 120)\n const companyId = typed(body['companyId'], 128)\n const ownerUid = typed(body['ownerUid'], 128)\n const address =\n body['address'] && typeof body['address'] === 'object'\n ? normalizeAddress(body['address'] as AglynPostalAddress)\n : null\n const tags = normalizeTags(body['tags'])\n const marketingConsent = body['marketingConsent'] === true\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const isStaff = decoded['staff'] === true\n if (!isStaff) {\n const membership = await resolveOrgMembership(decoded.uid, resolved.orgId)\n const member = membership?.member ?? null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(resolved.orgId, member, 'data.manage'))\n if (!allowed) {\n res.status(403).json({\n error: 'Adding contacts requires the data.manage permission on this site',\n })\n return\n }\n }\n\n // A person typed in by the team is the suite's; the capture doors that\n // fill a Free workspace's contacts do not come through here (AGL-2787).\n const suite = crmSuiteRefusal(resolved.org, 'Adding a contact by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n if (companyId) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const readable = new Set<string>(crmReadTokens(group))\n const companySnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .get()\n const tokens: unknown = companySnapshot.exists\n ? companySnapshot.get('visibleTo')\n : undefined\n const visible =\n Array.isArray(tokens) && tokens.some((token) => readable.has(String(token)))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n companyName = typed(companySnapshot.get('name'), 120) || companyName\n }\n\n const result = await captureHostContact({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'manual',\n interaction: { summary: 'Added by hand' },\n marketingConsent,\n facet: {\n ...(phone ? { phone } : {}),\n ...(jobTitle ? { jobTitle } : {}),\n ...(companyId ? { companyId } : {}),\n ...(address ? { address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(rawStage && isContactLifecycleStage(rawStage)\n ? { lifecycleStage: rawStage }\n : {}),\n },\n })\n\n if ('refused' in result) {\n if (result.refused === 'band') {\n res.status(409).json({ error: CONTACT_BAND_FULL_MESSAGE, reason: 'band' })\n return\n }\n if (result.refused === 'invalid-email') {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n // The person was erased from this workspace (AGL-2623). A conflict,\n // like the band: the request was well-formed and the answer is a\n // standing fact about the address, not a fault in the call.\n if (result.refused === 'erased') {\n res.status(409).json({ error: CONTACT_ERASED_MESSAGE, reason: 'erased' })\n return\n }\n res.status(500).json({ error: 'The contact could not be saved.' })\n return\n }\n\n if (tags.length || companyName) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n await contacts.doc(result.contactId).update({\n ...(tags.length\n ? {\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(\n ...tags,\n ),\n }\n : {}),\n ...(companyName\n ? {\n [contactFacetPath(group.groupId, 'companyName')]: companyName,\n // The search echo — see `HostContact.companyName`.\n companyName,\n }\n : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n\n /*\n * The audit line, written HERE rather than by the console (AGL-2622):\n * a route that verified the caller and performed the write is the one\n * writer that cannot record an act that did not happen, which is the\n * reason `check-activity-coverage.mjs` counts this file. A merge into\n * a person the org already held is said to be one — the row was\n * updated, not added — so the feed cannot claim two people for one.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n result.created ? 'Added contact' : 'Updated contact',\n { type: 'contact', id: result.contactId, name: name || email },\n )\n\n res\n .status(result.created ? 201 : 200)\n .json({ contactId: result.contactId, created: result.created })\n } catch (error) {\n console.error('[crm] contact create failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n\n/** Console API registration, named in `plugins.config.json` as `consoleApi`. */\nexport function registerCrmConsoleApi(): void {\n registerPluginApiRoute(CRM_API_ROUTES.ping, crmPingHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactStage, contactStageHandler)\n // Every other console write to a contact's facets (AGL-2804): the rules\n // cannot tell one facet field from another, so the plan is asked here.\n registerPluginApiRoute(CRM_API_ROUTES.contactUpdate, crmContactUpdateHandler)\n // A company's contacts unlinked, then the company (AGL-2804): the unlink\n // clears a facet, which is the server's to write.\n registerPluginApiRoute(CRM_API_ROUTES.companyDelete, crmCompanyDeleteHandler)\n // Tasks (AGL-2599): the two writes with a side effect outside the document\n // — an assignee's notification, and the `taskCompleted` host event.\n registerPluginApiRoute(CRM_TASK_ROUTES.save, crmTaskSaveHandler)\n registerPluginApiRoute(CRM_TASK_ROUTES.complete, crmTaskCompleteHandler)\n // A client-direct task write's door to `nextTaskAtMs` (AGL-2661).\n registerPluginApiRoute(CRM_NEXT_ACTIVITY_ROUTE, crmNextActivityHandler)\n // One chunk of a contact file (AGL-2602), judged and written through the\n // same door every capture uses.\n registerPluginApiRoute('crm/contacts-import', crmContactsImportHandler)\n // One chunk of a companies file (AGL-2621), matched by domain then name\n // and written with the stamp every CRM creator writes.\n registerPluginApiRoute('crm/companies-import', crmCompaniesImportHandler)\n // One chunk of a deals file and one of a tasks file (AGL-2662): the\n // pipeline, the stage and the assignee resolved by name, a row refused\n // when the org has no such name.\n registerPluginApiRoute('crm/deals-import', crmDealsImportHandler)\n registerPluginApiRoute('crm/tasks-import', crmTasksImportHandler)\n // One chunk of a leads file (AGL-2701), written through `addHostLead` —\n // the same door a sign-up, a booking and a form submission file a lead\n // through, so an imported row is keyed, bounded and unconsented exactly\n // as a captured one is.\n registerPluginApiRoute('crm/leads-import', crmLeadsImportHandler)\n // The one writer of a deal's stage, won and lost (AGL-2598): the browser\n // could write the field, but only a server can emit the event an\n // automation listens for.\n registerPluginApiRoute('crm/deal-stage', crmDealStageHandler)\n // A person typed into the console (AGL-2596): the capture doors' own\n // function behind a session check, so the dedupe and the band are judged\n // where every other door judges them.\n registerPluginApiRoute('crm/contacts-create', crmContactsCreateHandler)\n // A lead becomes a contact, a company and a deal (AGL-2608) — the one CRM\n // write a browser cannot make alone, because only the server may create a\n // contact through the dedupe-and-meter door.\n registerPluginApiRoute('crm/lead-convert', leadConvertHandler)\n // The one READ behind a route (AGL-2616): the per-recipient delivery log\n // is closed to clients, so a contact's campaign mail is projected here.\n registerPluginApiRoute(CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler)\n // One email to one person from their record (AGL-2615): the recipient is\n // read off the record, the daily cap and both suppression lists are\n // judged, and the message leaves on the site's sending identity.\n registerPluginApiRoute(CRM_API_ROUTES.emailSend, crmEmailSendHandler)\n // Two records for one person become one (AGL-2625): the repoint of every\n // row naming the merged record and the transaction over both documents\n // are the server's, and the address index it writes is closed to clients.\n registerPluginApiRoute(CONTACTS_MERGE_ROUTE, contactsMergeHandler)\n // Files a person's privacy erasure for the daily job (AGL-2623);\n // workspace admins only.\n registerPluginApiRoute(CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler)\n // One line in the organization's activity feed for an act the org-level\n // hub performed client-direct (AGL-2634): the feed is closed to clients,\n // so the bulk bars' lines come through here.\n registerPluginApiRoute(CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler)\n // A recipe's action written into one site's automations from the org\n // hub (AGL-2639), stamped and deduplicated by the server, and the stamps\n // read back per site so the hub can say which sites carry which recipe.\n registerPluginApiRoute(CRM_RECIPE_INSTALL_ROUTE, crmRecipeInstallHandler)\n registerPluginApiRoute(CRM_RECIPE_STATUS_ROUTE, crmRecipeStatusHandler)\n // The workspace's email capture address (AGL-2657): the token is minted\n // and rotated here, behind the CRM's own gate, and the org document that\n // carries it is closed to every client.\n registerPluginApiRoute(CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler)\n registerPluginApiRoute(\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n )\n // The facts of a contact, company, deal or lead, and an import's field\n // catalog, for another plugin in this process (AGL-2917): read on the core's\n // record-facts seam under the rules every CRM door applies, and written\n // nowhere.\n registerCrmRecordFactsReaders()\n // The CRM as the workspace's record system on the core's record-timeline\n // seam (AGL-2981): another plugin files an email, a reply or a task on a\n // record through it, under the CRM's own scope, dedupe and ceiling.\n registerCrmRecordTimelineWriter()\n}\n"],"names":["CONTACT_ERASED_MESSAGE","contactFacetPath","CRM_COLLECTIONS","crmReadTokens","isContactLifecycleStage","isOrgWideMember","normalizeAddress","normalizeContactEmail","normalizePhone","readContactFacet","registerPluginApiRoute","visibleToHost","consentGroupForSite","firebaseAdmin","getOrgForHost","logHostActivity","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","isRefusedIdToken","captureHostContact","emitHostEvent","FieldValue","CRM_API_ROUTES","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmCompaniesImportHandler","crmContactsImportHandler","crmDealsImportHandler","crmLeadsImportHandler","crmTasksImportHandler","crmDealStageHandler","crmEmailSendHandler","leadConvertHandler","CONTACT_EMAIL_HISTORY_ROUTE","contactEmailHistoryHandler","CONTACTS_MERGE_ROUTE","contactsMergeHandler","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","CRM_ORG_ACTIVITY_ROUTE","crmOrgActivityHandler","CRM_INBOUND_ADDRESS_ROUTE","crmInboundAddressHandler","crmCompanyDeleteHandler","CONTACT_PHONE_REFUSAL","normalizeTags","typed","crmContactUpdateHandler","CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE","crmEmailTemplateDuplicateHandler","registerCrmRecordFactsReaders","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","CRM_RECIPE_STATUS_ROUTE","crmRecipeInstallHandler","crmRecipeStatusHandler","crmPingHandler","req","res","method","setHeader","status","json","error","ok","plugin","authorizeSiteEditor","hostId","hostSnapshot","authorization","String","headers","idToken","startsWith","slice","length","undefined","uid","app","auth","verifyIdToken","console","firestore","collection","doc","get","exists","memberRole","contactStageHandler","body","trim","contactId","requested","lifecycleStage","clearing","caller","facet","owner","suite","org","contactsRef","snapshot","group","data","groupId","previousStage","changed","ref","update","delete","updatedAt","serverTimestamp","email","CONTACT_BAND_FULL_MESSAGE","crmContactsCreateHandler","rawPhone","phone","rawStage","name","jobTitle","companyName","companyId","ownerUid","address","tags","marketingConsent","decoded","resolved","isStaff","member","membership","orgId","reaches","Boolean","hostAccess","allowed","readable","Set","companySnapshot","companies","tokens","visible","Array","isArray","some","token","has","result","source","interaction","summary","refused","reason","contacts","arrayUnion","created","type","id","registerCrmConsoleApi","ping","contactStage","contactUpdate","companyDelete","save","complete","emailSend"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/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 Contacts plugin's SERVER half (AGL-2595).\n *\n * Contacts CRM v1 had none: every console write was client-direct against\n * the Firestore rules, and the one server path that creates a contact —\n * `upsertHostContact` — belongs to the capture doors, not to this plugin.\n * v2 needs a server for the things a browser must not do alone: an import\n * that dedupes forty thousand rows, a bulk action over a selection, an\n * auto-association of contacts to companies by domain. Each of those lands\n * as a `crm/<route>` handler registered here.\n *\n * `crm/ping` exists so the wiring is PROVEN rather than assumed:\n * `plugins.config.json` names this module's register function and the\n * `crm` API prefix, the generated server manifest loads this file, and the\n * console's `/api/[...pluginApi]` dispatcher reaches the handler. A plugin\n * whose first real route also had to be its first wiring test would have\n * two things to debug at once.\n *\n * The real routes are the writes with a side effect outside the document:\n * `crm/contact-stage` (AGL-2605) exists for the EVENT — `contactStageChanged`\n * can only be emitted by a server path that performed the write, so a record\n * page that moved the stage client-direct would move the person and tell no\n * automation; the task routes (AGL-2599) carry an assignee's notification and\n * the `taskCompleted` event; `crm/contacts-import` (AGL-2602) pushes one chunk\n * of a file through the same capture door every other server door uses.\n * `crm/deal-stage` (AGL-2598) is the one writer of a deal's stage, won and lost,\n * because a stage change is what automations listen for (`server-deal-stage.ts`).\n * `crm/contacts-create` (AGL-2596) is a person typed into the console by a\n * member of the team — a server route because the dedupe against every\n * holder's rows and the audience band are judgments the browser cannot make.\n * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:\n * the rules cannot tell one field of a holder's facet from another, so they\n * leave a client nothing there but letting a holder go, and the plan is\n * asked here about the fields a save carries.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n contactFacetPath,\n CRM_COLLECTIONS,\n crmReadTokens,\n isContactLifecycleStage,\n isOrgWideMember,\n normalizeAddress,\n normalizeContactEmail,\n normalizePhone,\n type AglynPostalAddress,\n type PluginApiHandler,\n type PluginApiRequest,\n readContactFacet,\n registerPluginApiRoute,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { captureHostContact, emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CRM_API_ROUTES } from './constants/api-routes'\nimport { registerCrmServerDeclarations } from './declarations.server'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { CRM_NEXT_ACTIVITY_ROUTE } from './model/next-activity'\nimport { CRM_TASK_ROUTES } from './model/task-routes'\nimport { crmNextActivityHandler } from './server/next-activity-routes'\nimport { registerCrmRecordTimelineWriter } from './server/record-timeline'\nimport { crmTaskCompleteHandler, crmTaskSaveHandler } from './server/task-routes'\nimport { crmCompaniesImportHandler } from './server/companies-import'\nimport { crmContactsImportHandler } from './server/contacts-import'\nimport { crmDealsImportHandler } from './server/deals-import'\nimport { crmLeadsImportHandler } from './server/leads-import'\nimport { crmTasksImportHandler } from './server/tasks-import'\nimport { crmDealStageHandler } from './server-deal-stage'\nimport { crmEmailSendHandler } from './server/email-send'\nimport { leadConvertHandler } from './server/lead-convert'\nimport {\n CONTACT_EMAIL_HISTORY_ROUTE,\n contactEmailHistoryHandler,\n} from './server/contact-email-history'\nimport { CONTACTS_MERGE_ROUTE, contactsMergeHandler } from './server/contacts-merge'\nimport { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from './server/erase-person'\nimport { CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler } from './server/org-activity'\nimport { CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler } from './server/inbound-address'\nimport { crmCompanyDeleteHandler } from './server/company-delete'\nimport { CONTACT_PHONE_REFUSAL, normalizeTags, typed } from './server/contact-profile'\nimport { crmContactUpdateHandler } from './server/contact-update'\nimport {\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n} from './server/email-template-duplicate'\nimport { registerCrmRecordFactsReaders } from './server/record-facts'\nimport { crmSuiteRefusal } from './server/suite-gate'\nimport {\n CRM_RECIPE_INSTALL_ROUTE,\n CRM_RECIPE_STATUS_ROUTE,\n crmRecipeInstallHandler,\n crmRecipeStatusHandler,\n} from './server/recipe-routes'\n\n/**\n * `GET /api/crm/ping` → `{ ok: true, plugin: 'crm' }`.\n *\n * No auth, no host, no data: it answers whether the plugin's server bundle\n * was loaded and its routes registered, which is a fact about the process\n * and not about any org. Anything that reads a document goes behind the\n * same session and role checks the other plugins' console routes use.\n */\nexport const crmPingHandler: PluginApiHandler = (req, res) => {\n if (req.method !== 'GET') {\n res.setHeader('Allow', 'GET')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n res.status(200).json({ ok: true, plugin: BUNDLE_ID })\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/**\n * Who is calling, and whether they may edit this site's CRM.\n *\n * The same check the inbox, bookings and email console routes make: a\n * bearer ID token, verified, and an `admin` or `editor` role on the host\n * document. A contact is a site's business record, so the site's own role\n * map is the right authority; org-wide reach is not required, because a\n * site editor who can open the record in the console can already write\n * every other field on it client-direct.\n */\nasync function authorizeSiteEditor(\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ ok: true; uid: string } | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let uid: string\n try {\n uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the site editor could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const hostSnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return { ok: false, status: 403, error: 'Not a site admin or editor' }\n }\n return { ok: true, uid }\n}\n\n/**\n * `POST /api/crm/contact-stage` with `{ hostId, contactId, lifecycleStage }`.\n *\n * Writes the stage into THIS site's facet on the contact — a dotted\n * `update()`, never a top-level field, because the row is shared by every\n * site in the org and a stage is one holder's reading of the person — and\n * then announces `contactStageChanged` with the stage it replaced.\n *\n * A stage set to what it already is writes nothing and announces nothing:\n * the response says `changed: false`, and no automation listening for a\n * change hears one that did not happen.\n *\n * `lifecycleStage: null` CLEARS the stage (AGL-2804). A facet is the\n * server's to write, so \"not placed yet\" comes through the route that owns\n * the stage rather than as a client-direct delete, and it is refused to a\n * plan without the suite the way a move is. There is no event for \"no\n * stage\", so a clear announces nothing; an empty string names no stage and\n * is refused like any other the list does not have.\n *\n * The contact is looked up by id and then checked against `visibleTo`,\n * because the Admin SDK evaluates no rules: a caller who can edit site A\n * must not be able to restage a contact only site B holds by guessing its\n * id, and the scoped query the list uses is not available to a `doc()` read.\n */\nexport const contactStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n const requested: unknown = req.body?.lifecycleStage\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n // `null` clears the stage; anything else has to be one of the list's.\n const clearing = requested === null\n const lifecycleStage = clearing\n ? ''\n : isContactLifecycleStage(requested)\n ? requested\n : null\n if (lifecycleStage === null) {\n res.status(400).json({ error: 'Pick a lifecycle stage' })\n return\n }\n const caller = await authorizeSiteEditor(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n try {\n // A lifecycle stage is the suite's (AGL-2787) — see `suite-gate.ts`.\n const owner = await getOrgForHost(hostId)\n if (!owner) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(owner.org, \"Moving a contact's lifecycle stage\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n if (!snapshot.exists || !visibleToHost(snapshot.get('visibleTo'), hostId)) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(\n snapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const previousStage = facet.lifecycleStage ?? ''\n if (previousStage === lifecycleStage) {\n res\n .status(200)\n .json({ ok: true, changed: false, lifecycleStage, previousStage })\n return\n }\n await snapshot.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: clearing\n ? FieldValue.delete()\n : lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Awaited, not floated: a serverless response ending cancels in-flight\n // work, and the event is the reason this route exists. A clear has none.\n if (!clearing) {\n await emitHostEvent(hostId, 'contactStageChanged', {\n contactId,\n email: String(snapshot.get('email') ?? ''),\n lifecycleStage,\n previousStage,\n })\n }\n res\n .status(200)\n .json({ ok: true, changed: true, lifecycleStage, previousStage })\n } catch (error) {\n console.error('[crm] contact-stage failed', hostId, contactId, error)\n res.status(500).json({ error: 'The stage could not be changed' })\n }\n}\n\n/**\n * What the create route says when the band refused (AGL-2596).\n *\n * The contacts list's own alert, in the past tense: the list says new\n * visitors are \"no longer captured\", and this says the one contact the\n * reader just tried to add was not. The remedy is the same sentence in both\n * places, so a reader who sees one and then the other is told one thing.\n */\nexport const CONTACT_BAND_FULL_MESSAGE =\n 'Contact limit reached — this contact was not added. Upgrade in Billing ' +\n 'to keep collecting.'\n\n/**\n * `POST /api/crm/contacts-create` — a person added by hand (AGL-2596).\n *\n * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,\n * companyId?, address?, ownerUid?, lifecycleStage?, tags?,\n * marketingConsent? }`. Answers `{ contactId, created }`: `created` is\n * false when the address already belonged to somebody, in which case what\n * was typed MERGES into the existing row — the dedupe the shared address\n * book exists for, and the reason a second \"create\" of one person is not\n * an error.\n *\n * ## Who may call it\n *\n * The caller's ID token, then `data.manage` on the site's org — the same key\n * the console surface itself is gated on, resolved through the same\n * three-layer permission read the members route uses. A scoped member is\n * admitted only for a site they reach: the rules would refuse a browser\n * write outside their tokens, and a server route that admitted it would be\n * a way round the rules rather than a service in front of them.\n *\n * ## What it refuses, and how\n *\n * A malformed email or phone number is a 400 with a sentence the drawer can\n * show under the field. The band is a 409 carrying the list's own wording:\n * it is not a bad request and not a server fault, it is the plan saying no,\n * and the drawer relays the sentence rather than inventing one.\n *\n * ## The company (AGL-2613)\n *\n * `companyId` is the picker's choice, and it is checked before anything is\n * written: the company has to exist and be visible to the capturing site's\n * scope, or a caller could file a person under a record they cannot open —\n * the same refusal the lead conversion makes. It reaches the upsert as\n * `facet.companyId`, which is where the link is kept in step with its mirror\n * and the company's contacts count; a merge onto somebody already at another\n * company is a MOVE there, not a second link. The stored company's own name\n * is what is echoed, over whatever the client sent, because the name on the\n * record is the company's and not the form's.\n *\n * ## What it writes beyond the upsert\n *\n * `upsertHostContact` takes the profile — phone, title, address, owner,\n * stage, company — and writes it into the capturing group's facet. The two\n * things it does not take are written here by DOTTED path afterwards: the\n * tags, which union into whatever the person already carried, and the\n * company name — the picked company's, or free text for a person filed\n * under a name no company record carries yet. Dotted paths because this is\n * an `update()`, and only an update reads a dot as a path — a nested object\n * here would replace the facet map and take every other holder's records\n * with it.\n */\nexport const crmContactsCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n const hostId = typed(body['hostId'], 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body['email'])\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n const rawPhone = typed(body['phone'], 40)\n const phone = rawPhone ? normalizePhone(rawPhone) : null\n if (rawPhone && !phone) {\n res.status(400).json({ error: CONTACT_PHONE_REFUSAL })\n return\n }\n const rawStage = typed(body['lifecycleStage'], 40)\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n res.status(400).json({ error: 'Unknown lifecycle stage.' })\n return\n }\n const name = typed(body['name'], 120)\n const jobTitle = typed(body['jobTitle'], 120)\n let companyName = typed(body['companyName'], 120)\n const companyId = typed(body['companyId'], 128)\n const ownerUid = typed(body['ownerUid'], 128)\n const address =\n body['address'] && typeof body['address'] === 'object'\n ? normalizeAddress(body['address'] as AglynPostalAddress)\n : null\n const tags = normalizeTags(body['tags'])\n const marketingConsent = body['marketingConsent'] === true\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const isStaff = decoded['staff'] === true\n if (!isStaff) {\n const membership = await resolveOrgMembership(decoded.uid, resolved.orgId)\n const member = membership?.member ?? null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(resolved.orgId, member, 'data.manage'))\n if (!allowed) {\n res.status(403).json({\n error: 'Adding contacts requires the data.manage permission on this site',\n })\n return\n }\n }\n\n // A person typed in by the team is the suite's; the capture doors that\n // fill a Free workspace's contacts do not come through here (AGL-2787).\n const suite = crmSuiteRefusal(resolved.org, 'Adding a contact by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n if (companyId) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const readable = new Set<string>(crmReadTokens(group))\n const companySnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .get()\n const tokens: unknown = companySnapshot.exists\n ? companySnapshot.get('visibleTo')\n : undefined\n const visible =\n Array.isArray(tokens) && tokens.some((token) => readable.has(String(token)))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n companyName = typed(companySnapshot.get('name'), 120) || companyName\n }\n\n const result = await captureHostContact({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'manual',\n interaction: { summary: 'Added by hand' },\n marketingConsent,\n facet: {\n ...(phone ? { phone } : {}),\n ...(jobTitle ? { jobTitle } : {}),\n ...(companyId ? { companyId } : {}),\n ...(address ? { address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(rawStage && isContactLifecycleStage(rawStage)\n ? { lifecycleStage: rawStage }\n : {}),\n },\n })\n\n if ('refused' in result) {\n if (result.refused === 'band') {\n res.status(409).json({ error: CONTACT_BAND_FULL_MESSAGE, reason: 'band' })\n return\n }\n if (result.refused === 'invalid-email') {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n // The person was erased from this workspace (AGL-2623). A conflict,\n // like the band: the request was well-formed and the answer is a\n // standing fact about the address, not a fault in the call.\n if (result.refused === 'erased') {\n res.status(409).json({ error: CONTACT_ERASED_MESSAGE, reason: 'erased' })\n return\n }\n res.status(500).json({ error: 'The contact could not be saved.' })\n return\n }\n\n if (tags.length || companyName) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n await contacts.doc(result.contactId).update({\n ...(tags.length\n ? {\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(\n ...tags,\n ),\n }\n : {}),\n ...(companyName\n ? {\n [contactFacetPath(group.groupId, 'companyName')]: companyName,\n // The search echo — see `HostContact.companyName`.\n companyName,\n }\n : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n\n /*\n * The audit line, written HERE rather than by the console (AGL-2622):\n * a route that verified the caller and performed the write is the one\n * writer that cannot record an act that did not happen, which is the\n * reason `check-activity-coverage.mjs` counts this file. A merge into\n * a person the org already held is said to be one — the row was\n * updated, not added — so the feed cannot claim two people for one.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n result.created ? 'Added contact' : 'Updated contact',\n { type: 'contact', id: result.contactId, name: name || email },\n )\n\n res\n .status(result.created ? 201 : 200)\n .json({ contactId: result.contactId, created: result.created })\n } catch (error) {\n console.error('[crm] contact create failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n\n/** Console API registration, named in `plugins.config.json` as `consoleApi`. */\nexport function registerCrmConsoleApi(): void {\n /*\n * The contact-capture writer, again (AGL-3080). Both apps run it at boot\n * from their generated server-declarations manifest, and this is the\n * backstop for a process whose boot did not: the first CRM door to load\n * registers it. Registering twice replaces in place.\n */\n registerCrmServerDeclarations()\n registerPluginApiRoute(CRM_API_ROUTES.ping, crmPingHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactStage, contactStageHandler)\n // Every other console write to a contact's facets (AGL-2804): the rules\n // cannot tell one facet field from another, so the plan is asked here.\n registerPluginApiRoute(CRM_API_ROUTES.contactUpdate, crmContactUpdateHandler)\n // A company's contacts unlinked, then the company (AGL-2804): the unlink\n // clears a facet, which is the server's to write.\n registerPluginApiRoute(CRM_API_ROUTES.companyDelete, crmCompanyDeleteHandler)\n // Tasks (AGL-2599): the two writes with a side effect outside the document\n // — an assignee's notification, and the `taskCompleted` host event.\n registerPluginApiRoute(CRM_TASK_ROUTES.save, crmTaskSaveHandler)\n registerPluginApiRoute(CRM_TASK_ROUTES.complete, crmTaskCompleteHandler)\n // A client-direct task write's door to `nextTaskAtMs` (AGL-2661).\n registerPluginApiRoute(CRM_NEXT_ACTIVITY_ROUTE, crmNextActivityHandler)\n // One chunk of a contact file (AGL-2602), judged and written through the\n // same door every capture uses.\n registerPluginApiRoute('crm/contacts-import', crmContactsImportHandler)\n // One chunk of a companies file (AGL-2621), matched by domain then name\n // and written with the stamp every CRM creator writes.\n registerPluginApiRoute('crm/companies-import', crmCompaniesImportHandler)\n // One chunk of a deals file and one of a tasks file (AGL-2662): the\n // pipeline, the stage and the assignee resolved by name, a row refused\n // when the org has no such name.\n registerPluginApiRoute('crm/deals-import', crmDealsImportHandler)\n registerPluginApiRoute('crm/tasks-import', crmTasksImportHandler)\n // One chunk of a leads file (AGL-2701), written through `addHostLead` —\n // the same door a sign-up, a booking and a form submission file a lead\n // through, so an imported row is keyed, bounded and unconsented exactly\n // as a captured one is.\n registerPluginApiRoute('crm/leads-import', crmLeadsImportHandler)\n // The one writer of a deal's stage, won and lost (AGL-2598): the browser\n // could write the field, but only a server can emit the event an\n // automation listens for.\n registerPluginApiRoute('crm/deal-stage', crmDealStageHandler)\n // A person typed into the console (AGL-2596): the capture doors' own\n // function behind a session check, so the dedupe and the band are judged\n // where every other door judges them.\n registerPluginApiRoute('crm/contacts-create', crmContactsCreateHandler)\n // A lead becomes a contact, a company and a deal (AGL-2608) — the one CRM\n // write a browser cannot make alone, because only the server may create a\n // contact through the dedupe-and-meter door.\n registerPluginApiRoute('crm/lead-convert', leadConvertHandler)\n // The one READ behind a route (AGL-2616): the per-recipient delivery log\n // is closed to clients, so a contact's campaign mail is projected here.\n registerPluginApiRoute(CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler)\n // One email to one person from their record (AGL-2615): the recipient is\n // read off the record, the daily cap and both suppression lists are\n // judged, and the message leaves on the site's sending identity.\n registerPluginApiRoute(CRM_API_ROUTES.emailSend, crmEmailSendHandler)\n // Two records for one person become one (AGL-2625): the repoint of every\n // row naming the merged record and the transaction over both documents\n // are the server's, and the address index it writes is closed to clients.\n registerPluginApiRoute(CONTACTS_MERGE_ROUTE, contactsMergeHandler)\n // Files a person's privacy erasure for the daily job (AGL-2623);\n // workspace admins only.\n registerPluginApiRoute(CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler)\n // One line in the organization's activity feed for an act the org-level\n // hub performed client-direct (AGL-2634): the feed is closed to clients,\n // so the bulk bars' lines come through here.\n registerPluginApiRoute(CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler)\n // A recipe's action written into one site's automations from the org\n // hub (AGL-2639), stamped and deduplicated by the server, and the stamps\n // read back per site so the hub can say which sites carry which recipe.\n registerPluginApiRoute(CRM_RECIPE_INSTALL_ROUTE, crmRecipeInstallHandler)\n registerPluginApiRoute(CRM_RECIPE_STATUS_ROUTE, crmRecipeStatusHandler)\n // The workspace's email capture address (AGL-2657): the token is minted\n // and rotated here, behind the CRM's own gate, and the org document that\n // carries it is closed to every client.\n registerPluginApiRoute(CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler)\n registerPluginApiRoute(\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n )\n // The facts of a contact, company, deal or lead, and an import's field\n // catalog, for another plugin in this process (AGL-2917): read on the core's\n // record-facts seam under the rules every CRM door applies, and written\n // nowhere.\n registerCrmRecordFactsReaders()\n // The CRM as the workspace's record system on the core's record-timeline\n // seam (AGL-2981): another plugin files an email, a reply or a task on a\n // record through it, under the CRM's own scope, dedupe and ceiling.\n registerCrmRecordTimelineWriter()\n}\n"],"names":["CONTACT_ERASED_MESSAGE","contactFacetPath","CRM_COLLECTIONS","crmReadTokens","isContactLifecycleStage","isOrgWideMember","normalizeAddress","normalizeContactEmail","normalizePhone","readContactFacet","registerPluginApiRoute","visibleToHost","consentGroupForSite","firebaseAdmin","getOrgForHost","logHostActivity","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","isRefusedIdToken","captureHostContact","emitHostEvent","FieldValue","CRM_API_ROUTES","registerCrmServerDeclarations","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmCompaniesImportHandler","crmContactsImportHandler","crmDealsImportHandler","crmLeadsImportHandler","crmTasksImportHandler","crmDealStageHandler","crmEmailSendHandler","leadConvertHandler","CONTACT_EMAIL_HISTORY_ROUTE","contactEmailHistoryHandler","CONTACTS_MERGE_ROUTE","contactsMergeHandler","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","CRM_ORG_ACTIVITY_ROUTE","crmOrgActivityHandler","CRM_INBOUND_ADDRESS_ROUTE","crmInboundAddressHandler","crmCompanyDeleteHandler","CONTACT_PHONE_REFUSAL","normalizeTags","typed","crmContactUpdateHandler","CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE","crmEmailTemplateDuplicateHandler","registerCrmRecordFactsReaders","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","CRM_RECIPE_STATUS_ROUTE","crmRecipeInstallHandler","crmRecipeStatusHandler","crmPingHandler","req","res","method","setHeader","status","json","error","ok","plugin","authorizeSiteEditor","hostId","hostSnapshot","authorization","String","headers","idToken","startsWith","slice","length","undefined","uid","app","auth","verifyIdToken","console","firestore","collection","doc","get","exists","memberRole","contactStageHandler","body","trim","contactId","requested","lifecycleStage","clearing","caller","facet","owner","suite","org","contactsRef","snapshot","group","data","groupId","previousStage","changed","ref","update","delete","updatedAt","serverTimestamp","email","CONTACT_BAND_FULL_MESSAGE","crmContactsCreateHandler","rawPhone","phone","rawStage","name","jobTitle","companyName","companyId","ownerUid","address","tags","marketingConsent","decoded","resolved","isStaff","member","membership","orgId","reaches","Boolean","hostAccess","allowed","readable","Set","companySnapshot","companies","tokens","visible","Array","isArray","some","token","has","result","source","interaction","summary","refused","reason","contacts","arrayUnion","created","type","id","registerCrmConsoleApi","ping","contactStage","contactUpdate","companyDelete","save","complete","emailSend"],"mappings":"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"}