@aglyn/plugins-crm 1.0.0-beta.165 → 1.0.0-beta.167

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.
Files changed (50) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +7 -1
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/crm-console-page.js +12 -7
  5. package/src/lib/components/crm-console-page.js.map +1 -1
  6. package/src/lib/components/crm-glance-card.js +4 -2
  7. package/src/lib/components/crm-glance-card.js.map +1 -1
  8. package/src/lib/components/lead-campaigns-card.js +7 -1
  9. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  10. package/src/lib/components/lead-detail-page.js +1 -1
  11. package/src/lib/components/lead-detail-page.js.map +1 -1
  12. package/src/lib/components/lead-properties-card.js +1 -1
  13. package/src/lib/components/lead-properties-card.js.map +1 -1
  14. package/src/lib/components/lead-unqualify-dialog.js +7 -1
  15. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  16. package/src/lib/components/leads-bulk-bar.d.ts +1 -2
  17. package/src/lib/components/leads-bulk-bar.js +8 -3
  18. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  19. package/src/lib/components/leads-section.d.ts +9 -9
  20. package/src/lib/components/leads-section.js +47 -28
  21. package/src/lib/components/leads-section.js.map +1 -1
  22. package/src/lib/components/reports/lead-counts.d.ts +16 -9
  23. package/src/lib/components/reports/lead-counts.js +29 -18
  24. package/src/lib/components/reports/lead-counts.js.map +1 -1
  25. package/src/lib/components/reports/lead-funnel-card.js +44 -40
  26. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  27. package/src/lib/hooks/use-org-leads.d.ts +29 -32
  28. package/src/lib/hooks/use-org-leads.js +58 -68
  29. package/src/lib/hooks/use-org-leads.js.map +1 -1
  30. package/src/lib/model/crm-routes.d.ts +9 -6
  31. package/src/lib/model/crm-routes.js +9 -6
  32. package/src/lib/model/crm-routes.js.map +1 -1
  33. package/src/lib/server/capture-contact.js +11 -5
  34. package/src/lib/server/capture-contact.js.map +1 -1
  35. package/src/lib/server/convert-open-lead.js +6 -2
  36. package/src/lib/server/convert-open-lead.js.map +1 -1
  37. package/src/lib/server/email-send.js +3 -3
  38. package/src/lib/server/email-send.js.map +1 -1
  39. package/src/lib/server/erase-person.js +25 -7
  40. package/src/lib/server/erase-person.js.map +1 -1
  41. package/src/lib/server/lead-campaign-carry.js +3 -2
  42. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  43. package/src/lib/server/lead-create.js +8 -6
  44. package/src/lib/server/lead-create.js.map +1 -1
  45. package/src/lib/server/leads-import.js +2 -2
  46. package/src/lib/server/leads-import.js.map +1 -1
  47. package/src/lib/server/record-email-state.js +10 -0
  48. package/src/lib/server/record-email-state.js.map +1 -1
  49. package/src/lib/server/record-facts.js +3 -3
  50. package/src/lib/server/record-facts.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-org-leads.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n onSnapshot,\n orderBy,\n query,\n type DocumentData,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\n\n/** One site's lead, as the organization-level list carries it. */\nexport interface OrgLeadRow extends DocumentData {\n /**\n * The grid's row id: `{hostId}/{leadId}`. A lead's own id is a PERSON\n * KEY the same person on two sites has the same id on both so the\n * document id alone cannot key a list that spans sites.\n */\n $id: string\n /** `hosts/{hostId}/leads/{leadId}` — the document id. */\n leadId: string\n /** The site the lead lives under; what every write and link names. */\n hostId: string\n}\n\nexport interface OrgLeadsResult {\n /** Every site's window, merged and ordered newest-seen first, cut to the window. */\n data: OrgLeadRow[]\n /** `loading` until every site has answered once; `error` when any refused. */\n status: 'loading' | 'success' | 'error'\n /** Some site had more than its window — there are leads this list is not showing. */\n truncated: boolean\n}\n\n/**\n * THE ORGANIZATION'S LEADS, read a site at a time (AGL-2630).\n *\n * A lead lives under its site `hosts/{hostId}/leads`, host-scoped by\n * PATH — and there is no org-level collection to listen to, no `orgId` on\n * the document to group by, and no rule admitting a collection-group read.\n * So the org hub opens one listener per site the org has, each the exact\n * query the site's own Leads section runs, and merges the answers. An\n * org-wide member is a member of every site, which is what admits each\n * listener under the rules the site section already relies on; an org has\n * a handful of sites and at most thirty in a consent group, so the fan-out\n * is bounded by the org, not by the data.\n *\n * Each site's window is cut at `windowSize + 1`, the way the site section\n * cuts its own, and the merged list is cut again at `windowSize`: the list\n * is \"the most recently seen across the org\", and a site with a thousand\n * leads must not crowd out a site with ten. `truncated` says some site had\n * more, so the caption beneath the table can say the window is not the\n * whole collection.\n *\n * The listeners are torn down together when the site list changes a site\n * added or removed re-opens the set and nothing opens for an empty list,\n * which is also how the hook stays quiet under a site, where the section\n * hands it no sites at all.\n */\nexport function useOrgLeads(options: {\n /** The org's sites, by document id; empty opens nothing. */\n hostIds: readonly string[]\n windowSize: number\n}): OrgLeadsResult {\n const { hostIds, windowSize } = options\n const firestore = useFirestore()\n const key = hostIds.join('\\n')\n const [bySite, setBySite] = useState<\n Record<string, { rows: OrgLeadRow[]; truncated: boolean } | 'error'>\n >({})\n\n useEffect(() => {\n setBySite({})\n const sites = key ? key.split('\\n') : []\n if (!sites.length) return undefined\n const stops = sites.map((hostId) =>\n onSnapshot(\n query(\n collection(firestore, 'hosts', hostId, 'leads'),\n orderBy('lastSeenAtMs', 'desc'),\n limit(windowSize + 1),\n ),\n (snapshot) => {\n const rows = snapshot.docs.map((entry) => ({\n ...(entry.data() as DocumentData),\n $id: `${hostId}/${entry.id}`,\n leadId: entry.id,\n hostId,\n }))\n setBySite((current) => ({\n ...current,\n [hostId]: {\n rows: rows.slice(0, windowSize),\n truncated: rows.length > windowSize,\n },\n }))\n },\n (error) => {\n console.error(error)\n setBySite((current) => ({ ...current, [hostId]: 'error' }))\n },\n ),\n )\n return () => {\n for (const stop of stops) stop()\n }\n }, [firestore, key, windowSize])\n\n return useMemo(() => {\n const sites = key ? key.split('\\n') : []\n const answers = sites.map((hostId) => bySite[hostId])\n if (answers.some((answer) => answer === 'error')) {\n return { data: [], status: 'error' as const, truncated: false }\n }\n if (!sites.length || answers.some((answer) => !answer)) {\n return { data: [], status: 'loading' as const, truncated: false }\n }\n const merged = answers\n .flatMap((answer) => (answer === 'error' || !answer ? [] : answer.rows))\n .sort(\n (a, b) =>\n Number(b['lastSeenAtMs'] ?? 0) - Number(a['lastSeenAtMs'] ?? 0),\n )\n return {\n data: merged.slice(0, windowSize),\n status: 'success' as const,\n truncated:\n merged.length > windowSize ||\n answers.some((answer) => answer !== 'error' && answer?.truncated),\n }\n }, [bySite, key, windowSize])\n}\n\nexport default useOrgLeads\n"],"names":["useFirestore","collection","limit","onSnapshot","orderBy","query","useEffect","useMemo","useState","useOrgLeads","options","hostIds","windowSize","firestore","key","join","bySite","setBySite","sites","split","length","undefined","stops","map","hostId","snapshot","rows","docs","entry","data","$id","id","leadId","current","slice","truncated","error","console","stop","answers","some","answer","status","merged","flatMap","sort","a","b","Number"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,UAAU,EACVC,KAAK,EACLC,UAAU,EACVC,OAAO,EACPC,KAAK,QAEA,qBAAoB;AAC3B,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AAyBpD;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,YAAYC,OAI3B;IACC,MAAM,EAAEC,OAAO,EAAEC,UAAU,EAAE,GAAGF;IAChC,MAAMG,YAAYb;IAClB,MAAMc,MAAMH,QAAQI,IAAI,CAAC;IACzB,MAAM,CAACC,QAAQC,UAAU,GAAGT,SAE1B,CAAC;IAEHF,UAAU;QACRW,UAAU,CAAC;QACX,MAAMC,QAAQJ,MAAMA,IAAIK,KAAK,CAAC,QAAQ,EAAE;QACxC,IAAI,CAACD,MAAME,MAAM,EAAE,OAAOC;QAC1B,MAAMC,QAAQJ,MAAMK,GAAG,CAAC,CAACC,SACvBrB,WACEE,MACEJ,WAAWY,WAAW,SAASW,QAAQ,UACvCpB,QAAQ,gBAAgB,SACxBF,MAAMU,aAAa,KAErB,CAACa;gBACC,MAAMC,OAAOD,SAASE,IAAI,CAACJ,GAAG,CAAC,CAACK,QAAW,aACrCA,MAAMC,IAAI;wBACdC,KAAK,GAAGN,OAAO,CAAC,EAAEI,MAAMG,EAAE,EAAE;wBAC5BC,QAAQJ,MAAMG,EAAE;wBAChBP;;gBAEFP,UAAU,CAACgB,UAAa,aACnBA;wBACH,CAACT,OAAO,EAAE;4BACRE,MAAMA,KAAKQ,KAAK,CAAC,GAAGtB;4BACpBuB,WAAWT,KAAKN,MAAM,GAAGR;wBAC3B;;YAEJ,GACA,CAACwB;gBACCC,QAAQD,KAAK,CAACA;gBACdnB,UAAU,CAACgB,UAAa,aAAKA;wBAAS,CAACT,OAAO,EAAE;;YAClD;QAGJ,OAAO;YACL,KAAK,MAAMc,QAAQhB,MAAOgB;QAC5B;IACF,GAAG;QAACzB;QAAWC;QAAKF;KAAW;IAE/B,OAAOL,QAAQ;QACb,MAAMW,QAAQJ,MAAMA,IAAIK,KAAK,CAAC,QAAQ,EAAE;QACxC,MAAMoB,UAAUrB,MAAMK,GAAG,CAAC,CAACC,SAAWR,MAAM,CAACQ,OAAO;QACpD,IAAIe,QAAQC,IAAI,CAAC,CAACC,SAAWA,WAAW,UAAU;YAChD,OAAO;gBAAEZ,MAAM,EAAE;gBAAEa,QAAQ;gBAAkBP,WAAW;YAAM;QAChE;QACA,IAAI,CAACjB,MAAME,MAAM,IAAImB,QAAQC,IAAI,CAAC,CAACC,SAAW,CAACA,SAAS;YACtD,OAAO;gBAAEZ,MAAM,EAAE;gBAAEa,QAAQ;gBAAoBP,WAAW;YAAM;QAClE;QACA,MAAMQ,SAASJ,QACZK,OAAO,CAAC,CAACH,SAAYA,WAAW,WAAW,CAACA,SAAS,EAAE,GAAGA,OAAOf,IAAI,EACrEmB,IAAI,CACH,CAACC,GAAGC;gBACKA,iBAAiCD;mBAAxCE,QAAOD,kBAAAA,CAAC,CAAC,eAAe,YAAjBA,kBAAqB,KAAKC,QAAOF,kBAAAA,CAAC,CAAC,eAAe,YAAjBA,kBAAqB;;QAEnE,OAAO;YACLjB,MAAMc,OAAOT,KAAK,CAAC,GAAGtB;YACtB8B,QAAQ;YACRP,WACEQ,OAAOvB,MAAM,GAAGR,cAChB2B,QAAQC,IAAI,CAAC,CAACC,SAAWA,WAAW,YAAWA,0BAAAA,OAAQN,SAAS;QACpE;IACF,GAAG;QAACnB;QAAQF;QAAKF;KAAW;AAC9B;AAEA,eAAeH,YAAW"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-org-leads.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n type DocumentData,\n type QueryConstraint,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\n\n/** A lead, as the organization-level list carries it. */\nexport interface OrgLeadRow extends DocumentData {\n /**\n * The grid's row id, which is the lead's own document id.\n *\n * It used to be `{hostId}/{leadId}`, because a lead lived under its site\n * and the same person on two sites was two documents with the SAME id —\n * so the id alone could not key a list spanning sites. AGL-3275 made that\n * one document, and the compound key went with it.\n */\n $id: string\n /** `orgs/{orgId}/leads/{leadId}` — the document id, a person key. */\n leadId: string\n}\n\nexport interface OrgLeadsResult {\n /** The window, ordered newest-seen first. */\n data: OrgLeadRow[]\n /** `loading` until the listener has answered once; `error` when it refused. */\n status: 'loading' | 'success' | 'error'\n /** There are leads beyond the window this list is showing. */\n truncated: boolean\n}\n\n/**\n * THE ORGANIZATION'S LEADS, in one listener (AGL-3275).\n *\n * This opened one listener PER SITE and merged the answers, because a lead\n * lived at `hosts/{hostId}/leads` host-scoped by path, with no org-level\n * collection to listen to, no `orgId` on the document to group by, and no\n * rule admitting a collection-group read. All three of those are now false:\n * the collection is `orgs/{orgId}/leads` and a lead says who may see it.\n *\n * So the fan-out is gone, and with it the merge that had to cut each site's\n * window at `windowSize + 1` and the whole list again, so that a site with a\n * thousand leads could not crowd out a site with ten. One ordered query needs\n * neither: `truncated` is simply \"the window came back full\".\n *\n * `visibleTo` is the caller's, and `null` means \"ask for everything\"which\n * is what an org-wide member's listeners do, since the rules short-circuit on\n * `isOrgWideMember()` and a clause would only narrow what they may already\n * read. A scoped member passes their tokens. See `crmVisibleToClause`, which\n * builds the same clause for every other CRM listener.\n */\nexport function useOrgLeads(options: {\n orgId: string | null | undefined\n /** The scope clause, or `null` for an unscoped org-wide read. */\n visibleTo: readonly string[] | null\n windowSize: number\n}): OrgLeadsResult {\n const { orgId, visibleTo, windowSize } = options\n const firestore = useFirestore()\n const scopeKey = visibleTo ? visibleTo.join('\\n') : null\n const [answer, setAnswer] = useState<\n { rows: OrgLeadRow[]; truncated: boolean } | 'error' | null\n >(null)\n\n useEffect(() => {\n setAnswer(null)\n if (!orgId) return undefined\n // An `array-contains-any` over nothing is a query Firestore refuses, so a\n // scoped member with no tokens lists nothing rather than asking.\n const tokens = scopeKey === null ? null : scopeKey ? scopeKey.split('\\n') : []\n if (tokens && !tokens.length) {\n setAnswer({ rows: [], truncated: false })\n return undefined\n }\n const clauses: QueryConstraint[] = tokens\n ? [where('visibleTo', 'array-contains-any', tokens)]\n : []\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, 'leads'),\n ...clauses,\n orderBy('lastSeenAtMs', 'desc'),\n limit(windowSize + 1),\n ),\n (snapshot) => {\n const rows = snapshot.docs.map((entry) => ({\n ...(entry.data() as DocumentData),\n $id: entry.id,\n leadId: entry.id,\n }))\n setAnswer({\n rows: rows.slice(0, windowSize),\n truncated: rows.length > windowSize,\n })\n },\n (error) => {\n console.error(error)\n setAnswer('error')\n },\n )\n }, [firestore, orgId, scopeKey, windowSize])\n\n return useMemo(() => {\n if (answer === 'error') {\n return { data: [], status: 'error' as const, truncated: false }\n }\n if (!answer) return { data: [], status: 'loading' as const, truncated: false }\n return {\n data: answer.rows,\n status: 'success' as const,\n truncated: answer.truncated,\n }\n }, [answer])\n}\n\nexport default useOrgLeads\n"],"names":["useFirestore","collection","limit","onSnapshot","orderBy","query","where","useEffect","useMemo","useState","useOrgLeads","options","orgId","visibleTo","windowSize","firestore","scopeKey","join","answer","setAnswer","undefined","tokens","split","length","rows","truncated","clauses","snapshot","docs","map","entry","data","$id","id","leadId","slice","error","console","status"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,UAAU,EACVC,KAAK,EACLC,UAAU,EACVC,OAAO,EACPC,KAAK,EACLC,KAAK,QAGA,qBAAoB;AAC3B,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AA0BpD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,YAAYC,OAK3B;IACC,MAAM,EAAEC,KAAK,EAAEC,SAAS,EAAEC,UAAU,EAAE,GAAGH;IACzC,MAAMI,YAAYf;IAClB,MAAMgB,WAAWH,YAAYA,UAAUI,IAAI,CAAC,QAAQ;IACpD,MAAM,CAACC,QAAQC,UAAU,GAAGV,SAE1B;IAEFF,UAAU;QACRY,UAAU;QACV,IAAI,CAACP,OAAO,OAAOQ;QACnB,0EAA0E;QAC1E,iEAAiE;QACjE,MAAMC,SAASL,aAAa,OAAO,OAAOA,WAAWA,SAASM,KAAK,CAAC,QAAQ,EAAE;QAC9E,IAAID,UAAU,CAACA,OAAOE,MAAM,EAAE;YAC5BJ,UAAU;gBAAEK,MAAM,EAAE;gBAAEC,WAAW;YAAM;YACvC,OAAOL;QACT;QACA,MAAMM,UAA6BL,SAC/B;YAACf,MAAM,aAAa,sBAAsBe;SAAQ,GAClD,EAAE;QACN,OAAOlB,WACLE,MACEJ,WAAWc,WAAW,QAAQH,OAAO,aAClCc,SACHtB,QAAQ,gBAAgB,SACxBF,MAAMY,aAAa,KAErB,CAACa;YACC,MAAMH,OAAOG,SAASC,IAAI,CAACC,GAAG,CAAC,CAACC,QAAW,aACrCA,MAAMC,IAAI;oBACdC,KAAKF,MAAMG,EAAE;oBACbC,QAAQJ,MAAMG,EAAE;;YAElBd,UAAU;gBACRK,MAAMA,KAAKW,KAAK,CAAC,GAAGrB;gBACpBW,WAAWD,KAAKD,MAAM,GAAGT;YAC3B;QACF,GACA,CAACsB;YACCC,QAAQD,KAAK,CAACA;YACdjB,UAAU;QACZ;IAEJ,GAAG;QAACJ;QAAWH;QAAOI;QAAUF;KAAW;IAE3C,OAAON,QAAQ;QACb,IAAIU,WAAW,SAAS;YACtB,OAAO;gBAAEa,MAAM,EAAE;gBAAEO,QAAQ;gBAAkBb,WAAW;YAAM;QAChE;QACA,IAAI,CAACP,QAAQ,OAAO;YAAEa,MAAM,EAAE;YAAEO,QAAQ;YAAoBb,WAAW;QAAM;QAC7E,OAAO;YACLM,MAAMb,OAAOM,IAAI;YACjBc,QAAQ;YACRb,WAAWP,OAAOO,SAAS;QAC7B;IACF,GAAG;QAACP;KAAO;AACb;AAEA,eAAeR,YAAW"}
@@ -54,13 +54,16 @@ export declare function crmRoutes(basePath: string): {
54
54
  */
55
55
  contactByEmail: (email: string) => string;
56
56
  /**
57
- * A lead's page and, at the ORGANIZATION level (AGL-2630), the site
58
- * it lives under, as a segment before the id: a lead's id is a person
59
- * key, the same on every site that met the person, and `hosts/{hostId}
60
- * /leads` is host-scoped by path, so an address that spans sites has to
61
- * name one. Under a site the address is the id alone, as it always was.
57
+ * A lead's page: the id alone, at both levels (AGL-3275).
58
+ *
59
+ * This carried the SITE as a segment before the id at the organization
60
+ * level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`
61
+ * was host-scoped by path, and so an address that two sites had both met
62
+ * was two documents with the same id — which the id alone could not tell
63
+ * apart. One org collection makes the person key unambiguous again, and a
64
+ * lead addresses like every other CRM record.
62
65
  */
63
- lead: (id: string, hostId?: string | null) => string;
66
+ lead: (id: string) => string;
64
67
  company: (id: string) => string;
65
68
  deal: (id: string) => string;
66
69
  /**
@@ -57,12 +57,15 @@ import { CRM_VIEW_PARAM } from "./crm-view-param.js";
57
57
  [CONTACTS_LIST_EMAIL_PARAM]: email
58
58
  }).toString()}`,
59
59
  /**
60
- * A lead's page and, at the ORGANIZATION level (AGL-2630), the site
61
- * it lives under, as a segment before the id: a lead's id is a person
62
- * key, the same on every site that met the person, and `hosts/{hostId}
63
- * /leads` is host-scoped by path, so an address that spans sites has to
64
- * name one. Under a site the address is the id alone, as it always was.
65
- */ lead: (id, hostId)=>hostId ? `${section('leads')}/${encodeURIComponent(hostId)}/${encodeURIComponent(id)}` : `${section('leads')}/${encodeURIComponent(id)}`,
60
+ * A lead's page: the id alone, at both levels (AGL-3275).
61
+ *
62
+ * This carried the SITE as a segment before the id at the organization
63
+ * level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`
64
+ * was host-scoped by path, and so an address that two sites had both met
65
+ * was two documents with the same id which the id alone could not tell
66
+ * apart. One org collection makes the person key unambiguous again, and a
67
+ * lead addresses like every other CRM record.
68
+ */ lead: (id)=>`${section('leads')}/${encodeURIComponent(id)}`,
66
69
  company: (id)=>`${section('companies')}/${encodeURIComponent(id)}`,
67
70
  deal: (id)=>`${section('deals')}/${encodeURIComponent(id)}`,
68
71
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CRM_CONTACTS_EMAIL_PARAM, type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport type { CrmConsoleSectionId } from '../components/crm-console-sections'\nimport { CRM_VIEW_PARAM } from './crm-view-param'\n\n/**\n * Every address inside the Contacts surface, built from the one string the\n * shell hands the page (AGL-2595).\n *\n * The hub owns three kinds of deeper route — `people/{contactId}`,\n * `companies/{companyId}`, `deals/{dealId}` — and eight surfaces link into\n * them: a deal names its contact and company, a task names all three, a\n * report drills into a deal, an activity names whoever it happened with.\n * Eight surfaces each spelling `${basePath}/people/${id}` is eight places\n * for one of them to spell it `contacts/`, and a link that 404s inside the\n * hub is indistinguishable from a record that was deleted.\n *\n * The id is URL-encoded because Firestore ids are opaque: the console mints\n * them, but an import or an API caller may not, and a slash in an id would\n * otherwise read as a further segment.\n */\nexport function crmRoutes(basePath: string) {\n const section = (id: CrmConsoleSectionId) => `${basePath}/${id}`\n return {\n section,\n contact: (id: string) => `${section('contacts')}/${encodeURIComponent(id)}`,\n /**\n * The Contacts list narrowed to the people one form captured (AGL-2612):\n * source `form`, and the `formIds` filter on the form's id. The form's\n * own page links here; the list reads the two keys back through\n * `contactsListSeed`, which is the other half of this address.\n */\n contactsByForm: (formId: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_SOURCE_PARAM]: 'form',\n [CONTACTS_LIST_FORM_PARAM]: formId,\n }).toString()}`,\n /**\n * The Contacts list asked to OPEN the one person with this address\n * (AGL-2612). The list is the lookup: a contact's id is minted at\n * capture and nothing outside the CRM holds it, so a surface that has\n * only an email — an Inbox submission row — links here, the list\n * filters on the address (a whole-collection query, under the scope\n * the viewer may read) and moves straight on to the record when exactly\n * one matches. No match leaves the filtered list on screen, which is\n * the honest answer for a submission whose contact the band dropped.\n */\n contactByEmail: (email: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_EMAIL_PARAM]: email,\n }).toString()}`,\n /**\n * A lead's page and, at the ORGANIZATION level (AGL-2630), the site\n * it lives under, as a segment before the id: a lead's id is a person\n * key, the same on every site that met the person, and `hosts/{hostId}\n * /leads` is host-scoped by path, so an address that spans sites has to\n * name one. Under a site the address is the id alone, as it always was.\n */\n lead: (id: string, hostId?: string | null) =>\n hostId\n ? `${section('leads')}/${encodeURIComponent(hostId)}/${encodeURIComponent(id)}`\n : `${section('leads')}/${encodeURIComponent(id)}`,\n company: (id: string) => `${section('companies')}/${encodeURIComponent(id)}`,\n deal: (id: string) => `${section('deals')}/${encodeURIComponent(id)}`,\n /**\n * A section opened on one of its saved views (AGL-2617). The list reads\n * the key back through `crmViewIdFromParams`; the same key composes\n * with the Contacts seeds above, which is why it is a query key and not\n * a path segment.\n */\n sectionView: (id: CrmConsoleSectionId, viewId: string) =>\n `${section(id)}?${new URLSearchParams({\n [CRM_VIEW_PARAM]: viewId,\n }).toString()}`,\n }\n}\n\n/**\n * The query keys the Contacts list reads on arrival — written by the two\n * builders above and parsed by `contactsListSeed`, named once so neither\n * side can misspell the other.\n */\nexport const CONTACTS_LIST_SOURCE_PARAM = 'source'\nexport const CONTACTS_LIST_FORM_PARAM = 'formId'\n/**\n * The email key is the SHARED constant (AGL-2622): the console app — which\n * the module boundaries keep from importing this plugin — builds the same\n * address through `crmContactByEmailHref`, and `crm-routes.spec.ts` pins\n * `contactByEmail` against it so the plugin and the app cannot spell the\n * key two ways.\n */\nexport const CONTACTS_LIST_EMAIL_PARAM = CRM_CONTACTS_EMAIL_PARAM\n\nexport type CrmRoutes = ReturnType<typeof crmRoutes>\n\n/**\n * What the hub hands a record page: the shell's context, the record's id and\n * the surface's own path so the page can link back to its list.\n *\n * `basePath` is required here where the shell's prop is optional, because a\n * record page is only ever reached THROUGH the hub, which has already refused\n * to render without one.\n */\nexport type CrmDetailPageProps = Pick<\n ConsolePluginPageProps,\n 'hostId' | 'org' | 'permissions' | 'releaseFlag' | 'hostRole'\n> & {\n id: string\n basePath: string\n}\n"],"names":["CRM_CONTACTS_EMAIL_PARAM","CRM_VIEW_PARAM","crmRoutes","basePath","section","id","contact","encodeURIComponent","contactsByForm","formId","URLSearchParams","CONTACTS_LIST_SOURCE_PARAM","CONTACTS_LIST_FORM_PARAM","toString","contactByEmail","email","CONTACTS_LIST_EMAIL_PARAM","lead","hostId","company","deal","sectionView","viewId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAqC,eAAc;AAEpF,SAASC,cAAc,QAAQ,sBAAkB;AAEjD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,UAAUC,QAAgB;IACxC,MAAMC,UAAU,CAACC,KAA4B,GAAGF,SAAS,CAAC,EAAEE,IAAI;IAChE,OAAO;QACLD;QACAE,SAAS,CAACD,KAAe,GAAGD,QAAQ,YAAY,CAAC,EAAEG,mBAAmBF,KAAK;QAC3E;;;;;KAKC,GACDG,gBAAgB,CAACC,SACf,GAAGL,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACC,2BAA2B,EAAE;gBAC9B,CAACC,yBAAyB,EAAEH;YAC9B,GAAGI,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDC,gBAAgB,CAACC,QACf,GAAGX,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACM,0BAA0B,EAAED;YAC/B,GAAGF,QAAQ,IAAI;QACjB;;;;;;KAMC,GACDI,MAAM,CAACZ,IAAYa,SACjBA,SACI,GAAGd,QAAQ,SAAS,CAAC,EAAEG,mBAAmBW,QAAQ,CAAC,EAAEX,mBAAmBF,KAAK,GAC7E,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrDc,SAAS,CAACd,KAAe,GAAGD,QAAQ,aAAa,CAAC,EAAEG,mBAAmBF,KAAK;QAC5Ee,MAAM,CAACf,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrE;;;;;KAKC,GACDgB,aAAa,CAAChB,IAAyBiB,SACrC,GAAGlB,QAAQC,IAAI,CAAC,EAAE,IAAIK,gBAAgB;gBACpC,CAACT,eAAe,EAAEqB;YACpB,GAAGT,QAAQ,IAAI;IACnB;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAMF,6BAA6B,SAAQ;AAClD,OAAO,MAAMC,2BAA2B,SAAQ;AAChD;;;;;;CAMC,GACD,OAAO,MAAMI,4BAA4BhB,yBAAwB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CRM_CONTACTS_EMAIL_PARAM, type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport type { CrmConsoleSectionId } from '../components/crm-console-sections'\nimport { CRM_VIEW_PARAM } from './crm-view-param'\n\n/**\n * Every address inside the Contacts surface, built from the one string the\n * shell hands the page (AGL-2595).\n *\n * The hub owns three kinds of deeper route — `people/{contactId}`,\n * `companies/{companyId}`, `deals/{dealId}` — and eight surfaces link into\n * them: a deal names its contact and company, a task names all three, a\n * report drills into a deal, an activity names whoever it happened with.\n * Eight surfaces each spelling `${basePath}/people/${id}` is eight places\n * for one of them to spell it `contacts/`, and a link that 404s inside the\n * hub is indistinguishable from a record that was deleted.\n *\n * The id is URL-encoded because Firestore ids are opaque: the console mints\n * them, but an import or an API caller may not, and a slash in an id would\n * otherwise read as a further segment.\n */\nexport function crmRoutes(basePath: string) {\n const section = (id: CrmConsoleSectionId) => `${basePath}/${id}`\n return {\n section,\n contact: (id: string) => `${section('contacts')}/${encodeURIComponent(id)}`,\n /**\n * The Contacts list narrowed to the people one form captured (AGL-2612):\n * source `form`, and the `formIds` filter on the form's id. The form's\n * own page links here; the list reads the two keys back through\n * `contactsListSeed`, which is the other half of this address.\n */\n contactsByForm: (formId: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_SOURCE_PARAM]: 'form',\n [CONTACTS_LIST_FORM_PARAM]: formId,\n }).toString()}`,\n /**\n * The Contacts list asked to OPEN the one person with this address\n * (AGL-2612). The list is the lookup: a contact's id is minted at\n * capture and nothing outside the CRM holds it, so a surface that has\n * only an email — an Inbox submission row — links here, the list\n * filters on the address (a whole-collection query, under the scope\n * the viewer may read) and moves straight on to the record when exactly\n * one matches. No match leaves the filtered list on screen, which is\n * the honest answer for a submission whose contact the band dropped.\n */\n contactByEmail: (email: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_EMAIL_PARAM]: email,\n }).toString()}`,\n /**\n * A lead's page: the id alone, at both levels (AGL-3275).\n *\n * This carried the SITE as a segment before the id at the organization\n * level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`\n * was host-scoped by path, and so an address that two sites had both met\n * was two documents with the same id — which the id alone could not tell\n * apart. One org collection makes the person key unambiguous again, and a\n * lead addresses like every other CRM record.\n */\n lead: (id: string) => `${section('leads')}/${encodeURIComponent(id)}`,\n company: (id: string) => `${section('companies')}/${encodeURIComponent(id)}`,\n deal: (id: string) => `${section('deals')}/${encodeURIComponent(id)}`,\n /**\n * A section opened on one of its saved views (AGL-2617). The list reads\n * the key back through `crmViewIdFromParams`; the same key composes\n * with the Contacts seeds above, which is why it is a query key and not\n * a path segment.\n */\n sectionView: (id: CrmConsoleSectionId, viewId: string) =>\n `${section(id)}?${new URLSearchParams({\n [CRM_VIEW_PARAM]: viewId,\n }).toString()}`,\n }\n}\n\n/**\n * The query keys the Contacts list reads on arrival — written by the two\n * builders above and parsed by `contactsListSeed`, named once so neither\n * side can misspell the other.\n */\nexport const CONTACTS_LIST_SOURCE_PARAM = 'source'\nexport const CONTACTS_LIST_FORM_PARAM = 'formId'\n/**\n * The email key is the SHARED constant (AGL-2622): the console app — which\n * the module boundaries keep from importing this plugin — builds the same\n * address through `crmContactByEmailHref`, and `crm-routes.spec.ts` pins\n * `contactByEmail` against it so the plugin and the app cannot spell the\n * key two ways.\n */\nexport const CONTACTS_LIST_EMAIL_PARAM = CRM_CONTACTS_EMAIL_PARAM\n\nexport type CrmRoutes = ReturnType<typeof crmRoutes>\n\n/**\n * What the hub hands a record page: the shell's context, the record's id and\n * the surface's own path so the page can link back to its list.\n *\n * `basePath` is required here where the shell's prop is optional, because a\n * record page is only ever reached THROUGH the hub, which has already refused\n * to render without one.\n */\nexport type CrmDetailPageProps = Pick<\n ConsolePluginPageProps,\n 'hostId' | 'org' | 'permissions' | 'releaseFlag' | 'hostRole'\n> & {\n id: string\n basePath: string\n}\n"],"names":["CRM_CONTACTS_EMAIL_PARAM","CRM_VIEW_PARAM","crmRoutes","basePath","section","id","contact","encodeURIComponent","contactsByForm","formId","URLSearchParams","CONTACTS_LIST_SOURCE_PARAM","CONTACTS_LIST_FORM_PARAM","toString","contactByEmail","email","CONTACTS_LIST_EMAIL_PARAM","lead","company","deal","sectionView","viewId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAqC,eAAc;AAEpF,SAASC,cAAc,QAAQ,sBAAkB;AAEjD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,UAAUC,QAAgB;IACxC,MAAMC,UAAU,CAACC,KAA4B,GAAGF,SAAS,CAAC,EAAEE,IAAI;IAChE,OAAO;QACLD;QACAE,SAAS,CAACD,KAAe,GAAGD,QAAQ,YAAY,CAAC,EAAEG,mBAAmBF,KAAK;QAC3E;;;;;KAKC,GACDG,gBAAgB,CAACC,SACf,GAAGL,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACC,2BAA2B,EAAE;gBAC9B,CAACC,yBAAyB,EAAEH;YAC9B,GAAGI,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDC,gBAAgB,CAACC,QACf,GAAGX,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACM,0BAA0B,EAAED;YAC/B,GAAGF,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDI,MAAM,CAACZ,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrEa,SAAS,CAACb,KAAe,GAAGD,QAAQ,aAAa,CAAC,EAAEG,mBAAmBF,KAAK;QAC5Ec,MAAM,CAACd,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrE;;;;;KAKC,GACDe,aAAa,CAACf,IAAyBgB,SACrC,GAAGjB,QAAQC,IAAI,CAAC,EAAE,IAAIK,gBAAgB;gBACpC,CAACT,eAAe,EAAEoB;YACpB,GAAGR,QAAQ,IAAI;IACnB;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAMF,6BAA6B,SAAQ;AAClD,OAAO,MAAMC,2BAA2B,SAAQ;AAChD;;;;;;CAMC,GACD,OAAO,MAAMI,4BAA4BhB,yBAAwB"}
@@ -18,7 +18,7 @@ import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
18
18
  import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
19
19
  import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
20
20
  import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
21
- import { addHostLead, findContactByEmail, firebaseAdmin, orgDataCollectionForHost } from "@aglyn/tenant-data-admin";
21
+ import { addHostLead, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
22
22
  import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
23
23
  /**
24
24
  * The CRM answering the platform's contact-capture contract (AGL-3080).
@@ -119,8 +119,13 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
119
119
  /** Whether the site holds an OPEN lead for the address — neither converted nor closed. */ async function openLeadFor(request) {
120
120
  var _snapshot_data;
121
121
  const key = personKey(request.identity.email);
122
- const snapshot = await firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId).collection('leads').doc(key).get();
123
- if (!snapshot.exists) return false;
122
+ /*
123
+ * Through the seam, which reads the org row and falls back to a lead the
124
+ * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand
125
+ * in the same consent group has to find this person, or its door concludes
126
+ * there is no lead and files the duplicate this migration exists to end.
127
+ */ const snapshot = await readLeadForHost(request.hostId, key);
128
+ if (!snapshot) return false;
124
129
  const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
125
130
  return !lead.convertedContactId && isCrmLeadOpen(lead);
126
131
  }
@@ -133,8 +138,9 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
133
138
  const email = normalizeContactEmail(request.identity.email);
134
139
  const key = personKey(email);
135
140
  const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId);
136
- const leadRef = hostRef.collection('leads').doc(key);
137
- const created = !(await leadRef.get()).exists;
141
+ // `addHostLead` resolves and writes the org collection itself (AGL-3275);
142
+ // this only needs to know whether the person is new, which decides the event.
143
+ const created = !await readLeadForHost(request.hostId, key);
138
144
  const { formId } = entryPointOf(request.detail);
139
145
  const source = request.interaction.source === 'form' && formId ? `form:${formId}` : request.interaction.source;
140
146
  const stored = await addHostLead(_extends({
@@ -1 +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 {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\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 */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\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/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(request.hostId)\n .collection('leads')\n .doc(key)\n .get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n const leadRef = hostRef.collection('leads').doc(key)\n const created = !(await leadRef.get()).exists\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\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 {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\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","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","app","firestore","collection","doc","get","exists","lead","data","convertedContactId","hostRef","leadRef","created","formId","entryPointOf","detail","stored","name","marketingConsent","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
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 {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\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 */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\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/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\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 {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\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","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","stored","name","marketingConsent","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
@@ -40,7 +40,7 @@
40
40
  * Never throws: the capture that made the contact has already happened,
41
41
  * and a lead that could not be stamped is one somebody converts by hand.
42
42
  */ import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
43
- import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
43
+ import { firebaseAdmin, getOrgForHost, leadForWrite } from "@aglyn/tenant-data-admin";
44
44
  import { handOffLeadRecords } from "@aglyn/tenant-runtime/hand-off-lead";
45
45
  import { FieldValue } from "firebase-admin/firestore";
46
46
  /**
@@ -54,7 +54,11 @@ import { FieldValue } from "firebase-admin/firestore";
54
54
  try {
55
55
  var _snapshot_data;
56
56
  const firestore = firebaseAdmin.app().firestore();
57
- const leadRef = firestore.collection('hosts').doc(input.hostId).collection('leads').doc(key);
57
+ /*
58
+ * The org row, with any not-yet-backfilled predecessor carried onto it
59
+ * first (AGL-3275) — a conversion must not stamp `convertedContactId` on
60
+ * a host row that a later capture would then write past on the org.
61
+ */ const { ref: leadRef } = await leadForWrite(input.hostId, key);
58
62
  const snapshot = await leadRef.get();
59
63
  if (!snapshot.exists) return false;
60
64
  const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-open-lead.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 * An open lead, closed as converted because the person became a\n * relationship on their own (AGL-3232).\n *\n * Salesforce closes a lead when somebody converts it. Aglyn has a door that\n * makes a person a contact without anybody deciding anything — a member\n * account — and a lead left open behind it is a rep chasing a member. So a\n * relationship capture asks here, after the contact exists: the site's\n * lead for the address, if it is still open, is stamped converted onto\n * that contact, the way the convert dialog stamps one, with `convertedBy`\n * saying which door did it. The CRM's own module, because a lead is the\n * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it\n * calls it, and the one-record backfill stamps the same shape by hand.\n *\n * Only an OPEN lead: one already converted names its contact and must keep\n * naming it, and one closed as unqualified was a decision — a person the\n * team judged not real who then buys something is a fact for the contact's\n * timeline, not a reason to rewrite the team's verdict.\n *\n * Once stamped, the lead hands what was filed on it to the contact — its\n * activities, its tasks, and every plugin's records about it — exactly as\n * the convert dialog's conversion does (AGL-3233), so a lead closed by a\n * purchase leaves the same timeline behind as one a rep converted.\n *\n * Never throws: the capture that made the contact has already happened,\n * and a lead that could not be stamped is one somebody converts by hand.\n */\n\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** Which door closed the lead, recorded on it beside the conversion stamp. */\nexport type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill'\n\nexport interface ConvertOpenLeadOntoContactInput {\n hostId: string\n email: unknown\n /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */\n contactId: string\n by: LeadAutoConvertedBy\n}\n\n/**\n * Stamp the site's open lead for `email` as converted onto `contactId`.\n * `true` when a lead was stamped; `false` when the site holds none, holds\n * one that is not open, or the write failed.\n */\nexport async function convertOpenLeadOntoContact(\n input: ConvertOpenLeadOntoContactInput,\n): Promise<boolean> {\n const email = normalizeContactEmail(input.email)\n const key = email ? personKey(email) : null\n if (!key || !input.contactId) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n const leadRef = firestore\n .collection('hosts')\n .doc(input.hostId)\n .collection('leads')\n .doc(key)\n const snapshot = await leadRef.get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false\n await leadRef.set(\n {\n status: 'qualified',\n convertedContactId: input.contactId,\n convertedAtMs: Date.now(),\n convertedBy: input.by,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n const resolved = await getOrgForHost(input.hostId)\n if (resolved) {\n await handOffLeadRecords({\n firestore,\n orgId: resolved.orgId,\n hostId: input.hostId,\n leadId: key,\n contactId: input.contactId,\n email,\n by: input.by,\n })\n }\n return true\n } catch (error) {\n console.error('convertOpenLeadOntoContact failed', input.hostId, error)\n return false\n }\n}\n\nexport default convertOpenLeadOntoContact\n"],"names":["isCrmLeadOpen","normalizeContactEmail","personKey","firebaseAdmin","getOrgForHost","handOffLeadRecords","FieldValue","convertOpenLeadOntoContact","input","email","key","contactId","snapshot","firestore","app","leadRef","collection","doc","hostId","get","exists","lead","data","convertedContactId","set","status","convertedAtMs","Date","now","convertedBy","by","updatedAt","serverTimestamp","merge","resolved","orgId","leadId","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAEEA,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,aAAa,QAAQ,2BAA0B;AACvE,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,UAAU,QAAQ,2BAA0B;AAarD;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,KAAsC;IAEtC,MAAMC,QAAQR,sBAAsBO,MAAMC,KAAK;IAC/C,MAAMC,MAAMD,QAAQP,UAAUO,SAAS;IACvC,IAAI,CAACC,OAAO,CAACF,MAAMG,SAAS,EAAE,OAAO;IACrC,IAAI;YASYC;QARd,MAAMC,YAAYV,cAAcW,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UACbG,UAAU,CAAC,SACXC,GAAG,CAACT,MAAMU,MAAM,EAChBF,UAAU,CAAC,SACXC,GAAG,CAACP;QACP,MAAME,WAAW,MAAMG,QAAQI,GAAG;QAClC,IAAI,CAACP,SAASQ,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAQT,iBAAAA,SAASU,IAAI,cAAbV,iBAAmB,CAAC;QAClC,IAAIS,KAAKE,kBAAkB,IAAI,CAACvB,cAAcqB,OAAO,OAAO;QAC5D,MAAMN,QAAQS,GAAG,CACf;YACEC,QAAQ;YACRF,oBAAoBf,MAAMG,SAAS;YACnCe,eAAeC,KAAKC,GAAG;YACvBC,aAAarB,MAAMsB,EAAE;YACrBC,WAAWzB,WAAW0B,eAAe;QACvC,GACA;YAAEC,OAAO;QAAK;QAEhB,MAAMC,WAAW,MAAM9B,cAAcI,MAAMU,MAAM;QACjD,IAAIgB,UAAU;YACZ,MAAM7B,mBAAmB;gBACvBQ;gBACAsB,OAAOD,SAASC,KAAK;gBACrBjB,QAAQV,MAAMU,MAAM;gBACpBkB,QAAQ1B;gBACRC,WAAWH,MAAMG,SAAS;gBAC1BF;gBACAqB,IAAItB,MAAMsB,EAAE;YACd;QACF;QACA,OAAO;IACT,EAAE,OAAOO,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqC7B,MAAMU,MAAM,EAAEmB;QACjE,OAAO;IACT;AACF;AAEA,eAAe9B,2BAA0B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-open-lead.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 * An open lead, closed as converted because the person became a\n * relationship on their own (AGL-3232).\n *\n * Salesforce closes a lead when somebody converts it. Aglyn has a door that\n * makes a person a contact without anybody deciding anything — a member\n * account — and a lead left open behind it is a rep chasing a member. So a\n * relationship capture asks here, after the contact exists: the site's\n * lead for the address, if it is still open, is stamped converted onto\n * that contact, the way the convert dialog stamps one, with `convertedBy`\n * saying which door did it. The CRM's own module, because a lead is the\n * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it\n * calls it, and the one-record backfill stamps the same shape by hand.\n *\n * Only an OPEN lead: one already converted names its contact and must keep\n * naming it, and one closed as unqualified was a decision — a person the\n * team judged not real who then buys something is a fact for the contact's\n * timeline, not a reason to rewrite the team's verdict.\n *\n * Once stamped, the lead hands what was filed on it to the contact — its\n * activities, its tasks, and every plugin's records about it — exactly as\n * the convert dialog's conversion does (AGL-3233), so a lead closed by a\n * purchase leaves the same timeline behind as one a rep converted.\n *\n * Never throws: the capture that made the contact has already happened,\n * and a lead that could not be stamped is one somebody converts by hand.\n */\n\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost, leadForWrite } from '@aglyn/tenant-data-admin'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** Which door closed the lead, recorded on it beside the conversion stamp. */\nexport type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill'\n\nexport interface ConvertOpenLeadOntoContactInput {\n hostId: string\n email: unknown\n /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */\n contactId: string\n by: LeadAutoConvertedBy\n}\n\n/**\n * Stamp the site's open lead for `email` as converted onto `contactId`.\n * `true` when a lead was stamped; `false` when the site holds none, holds\n * one that is not open, or the write failed.\n */\nexport async function convertOpenLeadOntoContact(\n input: ConvertOpenLeadOntoContactInput,\n): Promise<boolean> {\n const email = normalizeContactEmail(input.email)\n const key = email ? personKey(email) : null\n if (!key || !input.contactId) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n /*\n * The org row, with any not-yet-backfilled predecessor carried onto it\n * first (AGL-3275) — a conversion must not stamp `convertedContactId` on\n * a host row that a later capture would then write past on the org.\n */\n const { ref: leadRef } = await leadForWrite(input.hostId, key)\n const snapshot = await leadRef.get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false\n await leadRef.set(\n {\n status: 'qualified',\n convertedContactId: input.contactId,\n convertedAtMs: Date.now(),\n convertedBy: input.by,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n const resolved = await getOrgForHost(input.hostId)\n if (resolved) {\n await handOffLeadRecords({\n firestore,\n orgId: resolved.orgId,\n hostId: input.hostId,\n leadId: key,\n contactId: input.contactId,\n email,\n by: input.by,\n })\n }\n return true\n } catch (error) {\n console.error('convertOpenLeadOntoContact failed', input.hostId, error)\n return false\n }\n}\n\nexport default convertOpenLeadOntoContact\n"],"names":["isCrmLeadOpen","normalizeContactEmail","personKey","firebaseAdmin","getOrgForHost","leadForWrite","handOffLeadRecords","FieldValue","convertOpenLeadOntoContact","input","email","key","contactId","snapshot","firestore","app","ref","leadRef","hostId","get","exists","lead","data","convertedContactId","set","status","convertedAtMs","Date","now","convertedBy","by","updatedAt","serverTimestamp","merge","resolved","orgId","leadId","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAEEA,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,aAAa,EAAEC,YAAY,QAAQ,2BAA0B;AACrF,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,UAAU,QAAQ,2BAA0B;AAarD;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,KAAsC;IAEtC,MAAMC,QAAQT,sBAAsBQ,MAAMC,KAAK;IAC/C,MAAMC,MAAMD,QAAQR,UAAUQ,SAAS;IACvC,IAAI,CAACC,OAAO,CAACF,MAAMG,SAAS,EAAE,OAAO;IACrC,IAAI;YAUYC;QATd,MAAMC,YAAYX,cAAcY,GAAG,GAAGD,SAAS;QAC/C;;;;KAIC,GACD,MAAM,EAAEE,KAAKC,OAAO,EAAE,GAAG,MAAMZ,aAAaI,MAAMS,MAAM,EAAEP;QAC1D,MAAME,WAAW,MAAMI,QAAQE,GAAG;QAClC,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAQR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QAClC,IAAIQ,KAAKE,kBAAkB,IAAI,CAACvB,cAAcqB,OAAO,OAAO;QAC5D,MAAMJ,QAAQO,GAAG,CACf;YACEC,QAAQ;YACRF,oBAAoBd,MAAMG,SAAS;YACnCc,eAAeC,KAAKC,GAAG;YACvBC,aAAapB,MAAMqB,EAAE;YACrBC,WAAWxB,WAAWyB,eAAe;QACvC,GACA;YAAEC,OAAO;QAAK;QAEhB,MAAMC,WAAW,MAAM9B,cAAcK,MAAMS,MAAM;QACjD,IAAIgB,UAAU;YACZ,MAAM5B,mBAAmB;gBACvBQ;gBACAqB,OAAOD,SAASC,KAAK;gBACrBjB,QAAQT,MAAMS,MAAM;gBACpBkB,QAAQzB;gBACRC,WAAWH,MAAMG,SAAS;gBAC1BF;gBACAoB,IAAIrB,MAAMqB,EAAE;YACd;QACF;QACA,OAAO;IACT,EAAE,OAAOO,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqC5B,MAAMS,MAAM,EAAEmB;QACjE,OAAO;IACT;AACF;AAEA,eAAe7B,2BAA0B"}
@@ -16,7 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { buildCrmEmailActivity, contactCaptureHostIds, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, CRM_EMAIL_BODY_MAX, CRM_EMAIL_CONTEXT, CRM_EMAIL_SUBJECT_MAX, crmActivityLogHasRoom, crmEmailDeliveryTags, emailStateRefusal, crmMergeFieldsIn, crmScopeTokens, isOrgWideMember, normalizeContactEmail, readContactFacet, readEmailState, readMarketingBasis, renderCrmMergeFields, resolveOrgEntitlements, visibleToHost } from "@aglyn/aglyn/server";
18
18
  import { isEmailConfigured, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
19
- import { consentGroupForSite, consumeRateLimit, countCrmActivitiesForRecord, filterSendableForHost, firebaseAdmin, getHostDocAdmin, getOrgForHost, hostSendingIdentity, logOrgActivity, memberHasOrgPermission, newCrmActivityRef, orgDataCollectionForHost, readOrgEmailRamp, recordEmailSends, releaseCrmEmailSend, reserveCrmEmailSend, resolveOrgMembership, writeCrmEmailActivity } from "@aglyn/tenant-data-admin";
19
+ import { consentGroupForSite, consumeRateLimit, countCrmActivitiesForRecord, filterSendableForHost, firebaseAdmin, getHostDocAdmin, getOrgForHost, hostSendingIdentity, logOrgActivity, memberHasOrgPermission, newCrmActivityRef, orgDataCollectionForHost, readLeadForHost, readOrgEmailRamp, recordEmailSends, releaseCrmEmailSend, reserveCrmEmailSend, resolveOrgMembership, writeCrmEmailActivity } from "@aglyn/tenant-data-admin";
20
20
  import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
21
21
  import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.js";
22
22
  import { crmSuiteRefusal } from "./suite-gate.js";
@@ -261,8 +261,8 @@ const refuse = (status, error, extra = {})=>({
261
261
  if (ids.leadId) {
262
262
  var _lead_data, _lead_get;
263
263
  if (!hostId) return refuse(400, 'Name the site the lead lives under.');
264
- const lead = await firestore.collection('hosts').doc(hostId).collection('leads').doc(ids.leadId).get();
265
- if (!lead.exists) return refuse(404, 'Unknown lead');
264
+ const lead = await readLeadForHost(hostId, ids.leadId);
265
+ if (!lead) return refuse(404, 'Unknown lead');
266
266
  const email = normalizeContactEmail(lead.get('email'));
267
267
  if (!email) return refuse(400, 'This lead has no email address.');
268
268
  const record = (_lead_data = lead.data()) != null ? _lead_data : {};