@aglyn/plugins-crm 1.0.0-beta.175 → 1.0.0-beta.177

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.175",
3
+ "version": "1.0.0-beta.177",
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.175",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.175",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.175",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.175",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.175",
33
- "@aglyn/shared-ui-next": "1.0.0-beta.175",
34
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.175",
35
- "@aglyn/shared-util-email": "1.0.0-beta.175",
36
- "@aglyn/shared-util-http": "1.0.0-beta.175",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.175",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.175",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.175",
28
+ "@aglyn/aglyn": "1.0.0-beta.177",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.177",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.177",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.177",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.177",
33
+ "@aglyn/shared-ui-next": "1.0.0-beta.177",
34
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.177",
35
+ "@aglyn/shared-util-email": "1.0.0-beta.177",
36
+ "@aglyn/shared-util-http": "1.0.0-beta.177",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.177",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.177",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.177",
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/utilities": "^3.2.2",
42
42
  "@swc/helpers": "0.5.23",
@@ -17,6 +17,7 @@
17
17
  // the registry and nothing else, and the barrel reaches the client contexts.
18
18
  import { registerPluginContactCaptureWriter } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
19
19
  import { registerPluginLeadConversionListener } from "@aglyn/aglyn/plugin-manager/plugin-lead-conversion";
20
+ import { registerPluginPersonMatcher } from "@aglyn/aglyn/plugin-manager/plugin-person-matches";
20
21
  import { BUNDLE_ID } from "./constants/bundle-common.js";
21
22
  /**
22
23
  * The CRM as the plugin that keeps people.
@@ -72,6 +73,15 @@ import { BUNDLE_ID } from "./constants/bundle-common.js";
72
73
  }, {
73
74
  pluginId: BUNDLE_ID
74
75
  });
76
+ // "Did this workspace already know this person?" (AGL-3289), asked by the
77
+ // staff console's acquisition card. Deferred like the rest: the reads load
78
+ // when a staff member first opens a card, not when the process starts.
79
+ registerPluginPersonMatcher(async (request)=>{
80
+ const { matchCrmPeople } = await import("./server/person-matches.js");
81
+ return matchCrmPeople(request);
82
+ }, {
83
+ pluginId: BUNDLE_ID
84
+ });
75
85
  }
76
86
 
77
87
  //# sourceMappingURL=declarations.server.js.map
@@ -1 +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 { registerPluginLeadConversionListener } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\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 // The CRM's own share of a lead conversion (AGL-3254): the lead's\n // campaigns go onto the contact's facet. Through the seam every door\n // that converts a lead reaches, and deferred like the capture: the\n // module that writes is loaded when the first conversion arrives, and\n // it brings the Admin SDK with it — this file defers the plugin's OWN\n // module only. A library imported statically across the plugin cannot\n // also be lazy-loaded here: `enforce-module-boundaries` refuses every\n // static import of a library the project lazy-loads anywhere.\n registerPluginLeadConversionListener(\n async (request) => {\n const { carryLeadCampaignsOnConversion } =\n await import('./server/lead-campaign-carry')\n return carryLeadCampaignsOnConversion(request)\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerPluginContactCaptureWriter","registerPluginLeadConversionListener","BUNDLE_ID","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SACEA,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,oCAAoC,QAAQ,qDAAoD;AACzG,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;IACdP,mCAAmCG,yBAAyB;QAC1DK,UAAUN;IACZ;IACA,kEAAkE;IAClE,qEAAqE;IACrE,mEAAmE;IACnE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,8DAA8D;IAC9DD,qCACE,OAAOI;QACL,MAAM,EAAEI,8BAA8B,EAAE,GACtC,MAAM,MAAM,CAAC;QACf,OAAOA,+BAA+BJ;IACxC,GACA;QAAEG,UAAUN;IAAU;AAE1B"}
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 { registerPluginLeadConversionListener } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonMatcher } from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\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 // The CRM's own share of a lead conversion (AGL-3254): the lead's\n // campaigns go onto the contact's facet. Through the seam every door\n // that converts a lead reaches, and deferred like the capture: the\n // module that writes is loaded when the first conversion arrives, and\n // it brings the Admin SDK with it — this file defers the plugin's OWN\n // module only. A library imported statically across the plugin cannot\n // also be lazy-loaded here: `enforce-module-boundaries` refuses every\n // static import of a library the project lazy-loads anywhere.\n registerPluginLeadConversionListener(\n async (request) => {\n const { carryLeadCampaignsOnConversion } =\n await import('./server/lead-campaign-carry')\n return carryLeadCampaignsOnConversion(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // \"Did this workspace already know this person?\" (AGL-3289), asked by the\n // staff console's acquisition card. Deferred like the rest: the reads load\n // when a staff member first opens a card, not when the process starts.\n registerPluginPersonMatcher(\n async (request) => {\n const { matchCrmPeople } = await import('./server/person-matches')\n return matchCrmPeople(request)\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerPluginContactCaptureWriter","registerPluginLeadConversionListener","registerPluginPersonMatcher","BUNDLE_ID","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion","matchCrmPeople"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SACEA,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,oCAAoC,QAAQ,qDAAoD;AACzG,SAASC,2BAA2B,QAAQ,oDAAmD;AAC/F,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;IACdR,mCAAmCI,yBAAyB;QAC1DK,UAAUN;IACZ;IACA,kEAAkE;IAClE,qEAAqE;IACrE,mEAAmE;IACnE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,8DAA8D;IAC9DF,qCACE,OAAOK;QACL,MAAM,EAAEI,8BAA8B,EAAE,GACtC,MAAM,MAAM,CAAC;QACf,OAAOA,+BAA+BJ;IACxC,GACA;QAAEG,UAAUN;IAAU;IAExB,0EAA0E;IAC1E,2EAA2E;IAC3E,uEAAuE;IACvED,4BACE,OAAOI;QACL,MAAM,EAAEK,cAAc,EAAE,GAAG,MAAM,MAAM,CAAC;QACxC,OAAOA,eAAeL;IACxB,GACA;QAAEG,UAAUN;IAAU;AAE1B"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type ConsolePluginPageProps } from '@aglyn/aglyn';
17
+ import type { ConsolePluginPageProps } from '@aglyn/aglyn/plugin-manager/feature-plugins';
18
18
  import type { CrmConsoleSectionId } from '../components/crm-console-sections';
19
19
  /**
20
20
  * Every address inside the Contacts surface, built from the one string the
@@ -13,7 +13,10 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { CRM_CONTACTS_EMAIL_PARAM } from "@aglyn/aglyn";
16
+ */ // Each from its own module, not the `@aglyn/aglyn` barrel: the server-side
17
+ // person matcher builds its links here, and the barrel reaches the client
18
+ // contexts no server graph may load.
19
+ import { CRM_CONTACTS_EMAIL_PARAM } from "@aglyn/aglyn/app-utils/console-record-links";
17
20
  import { CRM_VIEW_PARAM } from "./crm-view-param.js";
18
21
  /**
19
22
  * Every address inside the Contacts surface, built from the one string the
@@ -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: 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"}
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\n// Each from its own module, not the `@aglyn/aglyn` barrel: the server-side\n// person matcher builds its links here, and the barrel reaches the client\n// contexts no server graph may load.\nimport { CRM_CONTACTS_EMAIL_PARAM } from '@aglyn/aglyn/app-utils/console-record-links'\nimport type { ConsolePluginPageProps } from '@aglyn/aglyn/plugin-manager/feature-plugins'\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,2EAA2E;AAC3E,0EAA0E;AAC1E,qCAAqC;AACrC,SAASA,wBAAwB,QAAQ,8CAA6C;AAGtF,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"}
@@ -0,0 +1,20 @@
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
+ /** A name's words, without accents, case or punctuation; initials dropped. */
18
+ export declare function nameWords(name: string | null | undefined): string[];
19
+ /** Whether two names plausibly name one person. */
20
+ export declare function namesResemble(a: string | null | undefined, b: string | null | undefined): boolean;
@@ -0,0 +1,121 @@
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
+ * Whether two people's names plausibly belong to one person — the "possible"
18
+ * half of the sign-up cross-check (AGL-3289).
19
+ *
20
+ * Deliberately conservative, because a false "possible" sends a salesperson
21
+ * after the wrong person. Both names need a first and a last word; the LAST
22
+ * words must be identical; and the first words must be the same name — equal,
23
+ * one a clipped form of the other ("Matt"/"Matthew"), a common nickname
24
+ * ("Bob"/"Robert"), or one typo apart. A single-word name never matches: one
25
+ * word is too common to mean anyone in particular.
26
+ */ /** Nicknames that are not simply a clipped form of the full name. */ const NICKNAMES = {
27
+ bob: 'robert',
28
+ bobby: 'robert',
29
+ bill: 'william',
30
+ billy: 'william',
31
+ liam: 'william',
32
+ liz: 'elizabeth',
33
+ beth: 'elizabeth',
34
+ betty: 'elizabeth',
35
+ jim: 'james',
36
+ jimmy: 'james',
37
+ mike: 'michael',
38
+ dave: 'david',
39
+ kate: 'katherine',
40
+ katie: 'katherine',
41
+ kathy: 'katherine',
42
+ cathy: 'catherine',
43
+ tom: 'thomas',
44
+ tommy: 'thomas',
45
+ joe: 'joseph',
46
+ joey: 'joseph',
47
+ tony: 'anthony',
48
+ andy: 'andrew',
49
+ drew: 'andrew',
50
+ rick: 'richard',
51
+ dick: 'richard',
52
+ ted: 'edward',
53
+ eddie: 'edward',
54
+ jenny: 'jennifer',
55
+ meg: 'margaret',
56
+ maggie: 'margaret',
57
+ peggy: 'margaret',
58
+ sue: 'susan',
59
+ nate: 'nathan',
60
+ larry: 'lawrence',
61
+ charlie: 'charles',
62
+ chuck: 'charles',
63
+ hank: 'henry',
64
+ harry: 'henry',
65
+ jack: 'john',
66
+ johnny: 'john',
67
+ jon: 'john',
68
+ abby: 'abigail',
69
+ becky: 'rebecca',
70
+ vicky: 'victoria',
71
+ tori: 'victoria',
72
+ mandy: 'amanda'
73
+ };
74
+ /** A name's words, without accents, case or punctuation; initials dropped. */ export function nameWords(name) {
75
+ if (typeof name !== 'string') return [];
76
+ return name.normalize('NFD').replace(RegExp("[\\u0300-\\u036F\\u0483-\\u0489\\u0591-\\u05BD\\u05BF\\u05C1-\\u05C2\\u05C4-\\u05C5\\u05C7\\u0610-\\u061A\\u064B-\\u065F\\u0670\\u06D6-\\u06DC\\u06DF-\\u06E4\\u06E7-\\u06E8\\u06EA-\\u06ED\\u0711\\u0730-\\u074A\\u07A6-\\u07B0\\u07EB-\\u07F3\\u07FD\\u0816-\\u0819\\u081B-\\u0823\\u0825-\\u0827\\u0829-\\u082D\\u0859-\\u085B\\u0897-\\u089F\\u08CA-\\u08E1\\u08E3-\\u0903\\u093A-\\u093C\\u093E-\\u094F\\u0951-\\u0957\\u0962-\\u0963\\u0981-\\u0983\\u09BC\\u09BE-\\u09C4\\u09C7-\\u09C8\\u09CB-\\u09CD\\u09D7\\u09E2-\\u09E3\\u09FE\\u0A01-\\u0A03\\u0A3C\\u0A3E-\\u0A42\\u0A47-\\u0A48\\u0A4B-\\u0A4D\\u0A51\\u0A70-\\u0A71\\u0A75\\u0A81-\\u0A83\\u0ABC\\u0ABE-\\u0AC5\\u0AC7-\\u0AC9\\u0ACB-\\u0ACD\\u0AE2-\\u0AE3\\u0AFA-\\u0AFF\\u0B01-\\u0B03\\u0B3C\\u0B3E-\\u0B44\\u0B47-\\u0B48\\u0B4B-\\u0B4D\\u0B55-\\u0B57\\u0B62-\\u0B63\\u0B82\\u0BBE-\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCD\\u0BD7\\u0C00-\\u0C04\\u0C3C\\u0C3E-\\u0C44\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55-\\u0C56\\u0C62-\\u0C63\\u0C81-\\u0C83\\u0CBC\\u0CBE-\\u0CC4\\u0CC6-\\u0CC8\\u0CCA-\\u0CCD\\u0CD5-\\u0CD6\\u0CE2-\\u0CE3\\u0CF3\\u0D00-\\u0D03\\u0D3B-\\u0D3C\\u0D3E-\\u0D44\\u0D46-\\u0D48\\u0D4A-\\u0D4D\\u0D57\\u0D62-\\u0D63\\u0D81-\\u0D83\\u0DCA\\u0DCF-\\u0DD4\\u0DD6\\u0DD8-\\u0DDF\\u0DF2-\\u0DF3\\u0E31\\u0E34-\\u0E3A\\u0E47-\\u0E4E\\u0EB1\\u0EB4-\\u0EBC\\u0EC8-\\u0ECE\\u0F18-\\u0F19\\u0F35\\u0F37\\u0F39\\u0F3E-\\u0F3F\\u0F71-\\u0F84\\u0F86-\\u0F87\\u0F8D-\\u0F97\\u0F99-\\u0FBC\\u0FC6\\u102B-\\u103E\\u1056-\\u1059\\u105E-\\u1060\\u1062-\\u1064\\u1067-\\u106D\\u1071-\\u1074\\u1082-\\u108D\\u108F\\u109A-\\u109D\\u135D-\\u135F\\u1712-\\u1715\\u1732-\\u1734\\u1752-\\u1753\\u1772-\\u1773\\u17B4-\\u17D3\\u17DD\\u180B-\\u180D\\u180F\\u1885-\\u1886\\u18A9\\u1920-\\u192B\\u1930-\\u193B\\u1A17-\\u1A1B\\u1A55-\\u1A5E\\u1A60-\\u1A7C\\u1A7F\\u1AB0-\\u1ADD\\u1AE0-\\u1AEB\\u1B00-\\u1B04\\u1B34-\\u1B44\\u1B6B-\\u1B73\\u1B80-\\u1B82\\u1BA1-\\u1BAD\\u1BE6-\\u1BF3\\u1C24-\\u1C37\\u1CD0-\\u1CD2\\u1CD4-\\u1CE8\\u1CED\\u1CF4\\u1CF7-\\u1CF9\\u1DC0-\\u1DFF\\u20D0-\\u20F0\\u2CEF-\\u2CF1\\u2D7F\\u2DE0-\\u2DFF\\u302A-\\u302F\\u3099-\\u309A\\uA66F-\\uA672\\uA674-\\uA67D\\uA69E-\\uA69F\\uA6F0-\\uA6F1\\uA802\\uA806\\uA80B\\uA823-\\uA827\\uA82C\\uA880-\\uA881\\uA8B4-\\uA8C5\\uA8E0-\\uA8F1\\uA8FF\\uA926-\\uA92D\\uA947-\\uA953\\uA980-\\uA983\\uA9B3-\\uA9C0\\uA9E5\\uAA29-\\uAA36\\uAA43\\uAA4C-\\uAA4D\\uAA7B-\\uAA7D\\uAAB0\\uAAB2-\\uAAB4\\uAAB7-\\uAAB8\\uAABE-\\uAABF\\uAAC1\\uAAEB-\\uAAEF\\uAAF5-\\uAAF6\\uABE3-\\uABEA\\uABEC-\\uABED\\uFB1E\\uFE00-\\uFE0F\\uFE20-\\uFE2F\\u{101FD}\\u{102E0}\\u{10376}-\\u{1037A}\\u{10A01}-\\u{10A03}\\u{10A05}-\\u{10A06}\\u{10A0C}-\\u{10A0F}\\u{10A38}-\\u{10A3A}\\u{10A3F}\\u{10AE5}-\\u{10AE6}\\u{10D24}-\\u{10D27}\\u{10D69}-\\u{10D6D}\\u{10EAB}-\\u{10EAC}\\u{10EFA}-\\u{10EFF}\\u{10F46}-\\u{10F50}\\u{10F82}-\\u{10F85}\\u{11000}-\\u{11002}\\u{11038}-\\u{11046}\\u{11070}\\u{11073}-\\u{11074}\\u{1107F}-\\u{11082}\\u{110B0}-\\u{110BA}\\u{110C2}\\u{11100}-\\u{11102}\\u{11127}-\\u{11134}\\u{11145}-\\u{11146}\\u{11173}\\u{11180}-\\u{11182}\\u{111B3}-\\u{111C0}\\u{111C9}-\\u{111CC}\\u{111CE}-\\u{111CF}\\u{1122C}-\\u{11237}\\u{1123E}\\u{11241}\\u{112DF}-\\u{112EA}\\u{11300}-\\u{11303}\\u{1133B}-\\u{1133C}\\u{1133E}-\\u{11344}\\u{11347}-\\u{11348}\\u{1134B}-\\u{1134D}\\u{11357}\\u{11362}-\\u{11363}\\u{11366}-\\u{1136C}\\u{11370}-\\u{11374}\\u{113B8}-\\u{113C0}\\u{113C2}\\u{113C5}\\u{113C7}-\\u{113CA}\\u{113CC}-\\u{113D0}\\u{113D2}\\u{113E1}-\\u{113E2}\\u{11435}-\\u{11446}\\u{1145E}\\u{114B0}-\\u{114C3}\\u{115AF}-\\u{115B5}\\u{115B8}-\\u{115C0}\\u{115DC}-\\u{115DD}\\u{11630}-\\u{11640}\\u{116AB}-\\u{116B7}\\u{1171D}-\\u{1172B}\\u{1182C}-\\u{1183A}\\u{11930}-\\u{11935}\\u{11937}-\\u{11938}\\u{1193B}-\\u{1193E}\\u{11940}\\u{11942}-\\u{11943}\\u{119D1}-\\u{119D7}\\u{119DA}-\\u{119E0}\\u{119E4}\\u{11A01}-\\u{11A0A}\\u{11A33}-\\u{11A39}\\u{11A3B}-\\u{11A3E}\\u{11A47}\\u{11A51}-\\u{11A5B}\\u{11A8A}-\\u{11A99}\\u{11B60}-\\u{11B67}\\u{11C2F}-\\u{11C36}\\u{11C38}-\\u{11C3F}\\u{11C92}-\\u{11CA7}\\u{11CA9}-\\u{11CB6}\\u{11D31}-\\u{11D36}\\u{11D3A}\\u{11D3C}-\\u{11D3D}\\u{11D3F}-\\u{11D45}\\u{11D47}\\u{11D8A}-\\u{11D8E}\\u{11D90}-\\u{11D91}\\u{11D93}-\\u{11D97}\\u{11EF3}-\\u{11EF6}\\u{11F00}-\\u{11F01}\\u{11F03}\\u{11F34}-\\u{11F3A}\\u{11F3E}-\\u{11F42}\\u{11F5A}\\u{13440}\\u{13447}-\\u{13455}\\u{1611E}-\\u{1612F}\\u{16AF0}-\\u{16AF4}\\u{16B30}-\\u{16B36}\\u{16F4F}\\u{16F51}-\\u{16F87}\\u{16F8F}-\\u{16F92}\\u{16FE4}\\u{16FF0}-\\u{16FF1}\\u{1BC9D}-\\u{1BC9E}\\u{1CF00}-\\u{1CF2D}\\u{1CF30}-\\u{1CF46}\\u{1D165}-\\u{1D169}\\u{1D16D}-\\u{1D172}\\u{1D17B}-\\u{1D182}\\u{1D185}-\\u{1D18B}\\u{1D1AA}-\\u{1D1AD}\\u{1D242}-\\u{1D244}\\u{1DA00}-\\u{1DA36}\\u{1DA3B}-\\u{1DA6C}\\u{1DA75}\\u{1DA84}\\u{1DA9B}-\\u{1DA9F}\\u{1DAA1}-\\u{1DAAF}\\u{1E000}-\\u{1E006}\\u{1E008}-\\u{1E018}\\u{1E01B}-\\u{1E021}\\u{1E023}-\\u{1E024}\\u{1E026}-\\u{1E02A}\\u{1E08F}\\u{1E130}-\\u{1E136}\\u{1E2AE}\\u{1E2EC}-\\u{1E2EF}\\u{1E4EC}-\\u{1E4EF}\\u{1E5EE}-\\u{1E5EF}\\u{1E6E3}\\u{1E6E6}\\u{1E6EE}-\\u{1E6EF}\\u{1E6F5}\\u{1E8D0}-\\u{1E8D6}\\u{1E944}-\\u{1E94A}\\u{E0100}-\\u{E01EF}]", "gu"), '').toLowerCase().split(/[^a-z]+/).filter((word)=>word.length >= 2);
77
+ }
78
+ function withinOneEdit(a, b) {
79
+ if (Math.abs(a.length - b.length) > 1) return false;
80
+ let i = 0;
81
+ let j = 0;
82
+ let edits = 0;
83
+ while(i < a.length && j < b.length){
84
+ if (a[i] === b[j]) {
85
+ i++;
86
+ j++;
87
+ continue;
88
+ }
89
+ if (++edits > 1) return false;
90
+ if (a.length > b.length) i++;
91
+ else if (b.length > a.length) j++;
92
+ else {
93
+ i++;
94
+ j++;
95
+ }
96
+ }
97
+ return edits + (a.length - i) + (b.length - j) <= 1;
98
+ }
99
+ function sameFirstName(a, b) {
100
+ var _NICKNAMES_a, _NICKNAMES_b;
101
+ if (a === b) return true;
102
+ if (((_NICKNAMES_a = NICKNAMES[a]) != null ? _NICKNAMES_a : a) === ((_NICKNAMES_b = NICKNAMES[b]) != null ? _NICKNAMES_b : b)) return true;
103
+ const [short, long] = a.length <= b.length ? [
104
+ a,
105
+ b
106
+ ] : [
107
+ b,
108
+ a
109
+ ];
110
+ if (short.length >= 3 && long.startsWith(short)) return true;
111
+ return a.length >= 4 && b.length >= 4 && withinOneEdit(a, b);
112
+ }
113
+ /** Whether two names plausibly name one person. */ export function namesResemble(a, b) {
114
+ const left = nameWords(a);
115
+ const right = nameWords(b);
116
+ if (left.length < 2 || right.length < 2) return false;
117
+ if (left[left.length - 1] !== right[right.length - 1]) return false;
118
+ return sameFirstName(left[0], right[0]);
119
+ }
120
+
121
+ //# sourceMappingURL=person-name-match.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/person-name-match.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 * Whether two people's names plausibly belong to one person — the \"possible\"\n * half of the sign-up cross-check (AGL-3289).\n *\n * Deliberately conservative, because a false \"possible\" sends a salesperson\n * after the wrong person. Both names need a first and a last word; the LAST\n * words must be identical; and the first words must be the same name — equal,\n * one a clipped form of the other (\"Matt\"/\"Matthew\"), a common nickname\n * (\"Bob\"/\"Robert\"), or one typo apart. A single-word name never matches: one\n * word is too common to mean anyone in particular.\n */\n\n/** Nicknames that are not simply a clipped form of the full name. */\nconst NICKNAMES: Readonly<Record<string, string>> = {\n bob: 'robert',\n bobby: 'robert',\n bill: 'william',\n billy: 'william',\n liam: 'william',\n liz: 'elizabeth',\n beth: 'elizabeth',\n betty: 'elizabeth',\n jim: 'james',\n jimmy: 'james',\n mike: 'michael',\n dave: 'david',\n kate: 'katherine',\n katie: 'katherine',\n kathy: 'katherine',\n cathy: 'catherine',\n tom: 'thomas',\n tommy: 'thomas',\n joe: 'joseph',\n joey: 'joseph',\n tony: 'anthony',\n andy: 'andrew',\n drew: 'andrew',\n rick: 'richard',\n dick: 'richard',\n ted: 'edward',\n eddie: 'edward',\n jenny: 'jennifer',\n meg: 'margaret',\n maggie: 'margaret',\n peggy: 'margaret',\n sue: 'susan',\n nate: 'nathan',\n larry: 'lawrence',\n charlie: 'charles',\n chuck: 'charles',\n hank: 'henry',\n harry: 'henry',\n jack: 'john',\n johnny: 'john',\n jon: 'john',\n abby: 'abigail',\n becky: 'rebecca',\n vicky: 'victoria',\n tori: 'victoria',\n mandy: 'amanda',\n}\n\n/** A name's words, without accents, case or punctuation; initials dropped. */\nexport function nameWords(name: string | null | undefined): string[] {\n if (typeof name !== 'string') return []\n return name\n .normalize('NFD')\n .replace(/\\p{M}/gu, '')\n .toLowerCase()\n .split(/[^a-z]+/)\n .filter((word) => word.length >= 2)\n}\n\nfunction withinOneEdit(a: string, b: string): boolean {\n if (Math.abs(a.length - b.length) > 1) return false\n let i = 0\n let j = 0\n let edits = 0\n while (i < a.length && j < b.length) {\n if (a[i] === b[j]) {\n i++\n j++\n continue\n }\n if (++edits > 1) return false\n if (a.length > b.length) i++\n else if (b.length > a.length) j++\n else {\n i++\n j++\n }\n }\n return edits + (a.length - i) + (b.length - j) <= 1\n}\n\nfunction sameFirstName(a: string, b: string): boolean {\n if (a === b) return true\n if ((NICKNAMES[a] ?? a) === (NICKNAMES[b] ?? b)) return true\n const [short, long] = a.length <= b.length ? [a, b] : [b, a]\n if (short.length >= 3 && long.startsWith(short)) return true\n return a.length >= 4 && b.length >= 4 && withinOneEdit(a, b)\n}\n\n/** Whether two names plausibly name one person. */\nexport function namesResemble(a: string | null | undefined, b: string | null | undefined): boolean {\n const left = nameWords(a)\n const right = nameWords(b)\n if (left.length < 2 || right.length < 2) return false\n if (left[left.length - 1] !== right[right.length - 1]) return false\n return sameFirstName(left[0], right[0])\n}\n"],"names":["NICKNAMES","bob","bobby","bill","billy","liam","liz","beth","betty","jim","jimmy","mike","dave","kate","katie","kathy","cathy","tom","tommy","joe","joey","tony","andy","drew","rick","dick","ted","eddie","jenny","meg","maggie","peggy","sue","nate","larry","charlie","chuck","hank","harry","jack","johnny","jon","abby","becky","vicky","tori","mandy","nameWords","name","normalize","replace","toLowerCase","split","filter","word","length","withinOneEdit","a","b","Math","abs","i","j","edits","sameFirstName","short","long","startsWith","namesResemble","left","right"],"mappings":"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"}
@@ -0,0 +1,60 @@
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 { PluginPersonMatch, PluginPersonMatchRequest } from '@aglyn/aglyn/plugin-manager/plugin-person-matches';
18
+ /**
19
+ * The CRM's answer to "did this workspace already know this person?"
20
+ * (AGL-3289), registered through the platform's person-match seam and asked
21
+ * when staff read where an account came from.
22
+ *
23
+ * Contacts and leads are both searched: by the account's address exactly —
24
+ * the contact through its email index, the lead by its person key — and by
25
+ * the account's name, as a resemblance the card shows as "possible".
26
+ *
27
+ * The account's OWN record is left out. The platform files every new account
28
+ * into its sales workspace as a contact whose only source is `account`, so a
29
+ * record like that, first seen once the account existed, is the sign-up
30
+ * itself and says nothing about whether anybody knew them before.
31
+ */
32
+ /** How many records each collection's name scan reads, at most. */
33
+ export declare const PERSON_NAME_SCAN_LIMIT = 5000;
34
+ /** How many name resemblances are returned, at most. */
35
+ export declare const PERSON_NAME_MATCH_LIMIT = 5;
36
+ /** A contact or lead as the matcher reads it. */
37
+ export interface PersonCandidate {
38
+ kind: 'contact' | 'lead';
39
+ id: string;
40
+ name: string | null;
41
+ email: string | null;
42
+ sources: string[];
43
+ firstSeenAtMs: number | null;
44
+ }
45
+ /** A contact or lead document as a candidate. */
46
+ export declare function candidateFrom(kind: PersonCandidate['kind'], id: string, data: Record<string, unknown> | undefined): PersonCandidate;
47
+ /** Whether a record is the one the account's own sign-up made. */
48
+ export declare function isTheAccountsOwnRecord(candidate: PersonCandidate, accountCreatedAtMs: number | null): boolean;
49
+ /**
50
+ * What to report, from what was read: every address match that is not the
51
+ * account itself, then up to {@link PERSON_NAME_MATCH_LIMIT} name
52
+ * resemblances that are neither an address match nor the account itself.
53
+ */
54
+ export declare function selectPersonMatches(input: {
55
+ request: PluginPersonMatchRequest;
56
+ byEmail: readonly PersonCandidate[];
57
+ byName: readonly PersonCandidate[];
58
+ }): PluginPersonMatch[];
59
+ /** The matcher the CRM registers. Reads only. */
60
+ export declare function matchCrmPeople(request: PluginPersonMatchRequest): Promise<PluginPersonMatch[]>;
@@ -0,0 +1,153 @@
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 { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
17
+ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { personKey } from "@aglyn/aglyn/app-utils/person-key";
19
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
20
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
21
+ import { crmRoutes } from "../model/crm-routes.js";
22
+ import { namesResemble } from "../model/person-name-match.js";
23
+ /**
24
+ * The CRM's answer to "did this workspace already know this person?"
25
+ * (AGL-3289), registered through the platform's person-match seam and asked
26
+ * when staff read where an account came from.
27
+ *
28
+ * Contacts and leads are both searched: by the account's address exactly —
29
+ * the contact through its email index, the lead by its person key — and by
30
+ * the account's name, as a resemblance the card shows as "possible".
31
+ *
32
+ * The account's OWN record is left out. The platform files every new account
33
+ * into its sales workspace as a contact whose only source is `account`, so a
34
+ * record like that, first seen once the account existed, is the sign-up
35
+ * itself and says nothing about whether anybody knew them before.
36
+ */ /** How many records each collection's name scan reads, at most. */ export const PERSON_NAME_SCAN_LIMIT = 5000;
37
+ /** How many name resemblances are returned, at most. */ export const PERSON_NAME_MATCH_LIMIT = 5;
38
+ /** Slack between the account's creation and its own record being written. */ const OWN_RECORD_SLACK_MS = 10 * 60 * 1000;
39
+ function millis(value) {
40
+ if (typeof value === 'number' && value > 0) return value;
41
+ const timestamp = value;
42
+ return timestamp && typeof timestamp.toMillis === 'function' ? timestamp.toMillis() : null;
43
+ }
44
+ function sourcesOf(value) {
45
+ if (Array.isArray(value)) return value.filter((entry)=>typeof entry === 'string');
46
+ if (value && typeof value === 'object') {
47
+ return Object.entries(value).filter(([, flag])=>flag === true).map(([source])=>source);
48
+ }
49
+ return [];
50
+ }
51
+ /** A contact or lead document as a candidate. */ export function candidateFrom(kind, id, data) {
52
+ var _millis;
53
+ return {
54
+ kind,
55
+ id,
56
+ name: typeof (data == null ? void 0 : data['name']) === 'string' ? data['name'] : null,
57
+ email: normalizeContactEmail(data == null ? void 0 : data['email']) || null,
58
+ sources: sourcesOf(data == null ? void 0 : data['sources']),
59
+ firstSeenAtMs: (_millis = millis(data == null ? void 0 : data['firstSeenAtMs'])) != null ? _millis : millis(data == null ? void 0 : data['createdAt'])
60
+ };
61
+ }
62
+ /** Whether a record is the one the account's own sign-up made. */ export function isTheAccountsOwnRecord(candidate, accountCreatedAtMs) {
63
+ if (candidate.sources.length !== 1 || candidate.sources[0] !== 'account') return false;
64
+ if (accountCreatedAtMs === null || candidate.firstSeenAtMs === null) return true;
65
+ return candidate.firstSeenAtMs >= accountCreatedAtMs - OWN_RECORD_SLACK_MS;
66
+ }
67
+ /**
68
+ * What to report, from what was read: every address match that is not the
69
+ * account itself, then up to {@link PERSON_NAME_MATCH_LIMIT} name
70
+ * resemblances that are neither an address match nor the account itself.
71
+ */ export function selectPersonMatches(input) {
72
+ const { request } = input;
73
+ const email = normalizeContactEmail(request.email) || null;
74
+ const hub = request.orgSlug ? crmRoutes(buildRoute(Route.ORG_PLUGIN, {
75
+ orgSlug: request.orgSlug,
76
+ pluginSlug: 'crm'
77
+ })) : null;
78
+ const href = (candidate)=>hub ? candidate.kind === 'contact' ? hub.contact(candidate.id) : hub.section('leads') : null;
79
+ const toMatch = (candidate, basis)=>({
80
+ kind: candidate.kind,
81
+ id: candidate.id,
82
+ label: candidate.name || candidate.email || candidate.id,
83
+ email: candidate.email,
84
+ basis,
85
+ firstSeenAtMs: candidate.firstSeenAtMs,
86
+ sources: candidate.sources,
87
+ href: href(candidate)
88
+ });
89
+ const seen = new Set();
90
+ const matches = [];
91
+ for (const candidate of input.byEmail){
92
+ const key = `${candidate.kind}:${candidate.id}`;
93
+ if (seen.has(key)) continue;
94
+ seen.add(key);
95
+ if (isTheAccountsOwnRecord(candidate, request.accountCreatedAtMs)) continue;
96
+ matches.push(toMatch(candidate, 'email'));
97
+ }
98
+ let named = 0;
99
+ for (const candidate of input.byName){
100
+ if (named >= PERSON_NAME_MATCH_LIMIT) break;
101
+ const key = `${candidate.kind}:${candidate.id}`;
102
+ if (seen.has(key)) continue;
103
+ if (email && candidate.email === email) continue;
104
+ if (isTheAccountsOwnRecord(candidate, request.accountCreatedAtMs)) continue;
105
+ if (!namesResemble(request.name, candidate.name)) continue;
106
+ seen.add(key);
107
+ matches.push(toMatch(candidate, 'name'));
108
+ named++;
109
+ }
110
+ return matches;
111
+ }
112
+ /** The matcher the CRM registers. Reads only. */ export async function matchCrmPeople(request) {
113
+ const firestore = firebaseAdmin.app().firestore();
114
+ const org = firestore.collection('orgs').doc(request.orgId);
115
+ const contacts = org.collection('contacts');
116
+ const leads = org.collection('leads');
117
+ const email = normalizeContactEmail(request.email);
118
+ const byEmail = [];
119
+ const key = email ? personKey(email) : null;
120
+ if (email && key) {
121
+ const [contact, lead, alternates] = await Promise.all([
122
+ findContactByEmail(contacts, email),
123
+ leads.doc(key).get(),
124
+ contacts.where('alternateEmails', 'array-contains', email).limit(3).get()
125
+ ]);
126
+ if (contact) byEmail.push(candidateFrom('contact', contact.id, contact.data()));
127
+ if (lead.exists) byEmail.push(candidateFrom('lead', lead.id, lead.data()));
128
+ for (const doc of alternates.docs)byEmail.push(candidateFrom('contact', doc.id, doc.data()));
129
+ }
130
+ const byName = [];
131
+ if (request.name) {
132
+ const fields = [
133
+ 'name',
134
+ 'email',
135
+ 'sources',
136
+ 'createdAt',
137
+ 'firstSeenAtMs'
138
+ ];
139
+ const [contactNames, leadNames] = await Promise.all([
140
+ contacts.select(...fields).limit(PERSON_NAME_SCAN_LIMIT).get(),
141
+ leads.select(...fields).limit(PERSON_NAME_SCAN_LIMIT).get()
142
+ ]);
143
+ for (const doc of contactNames.docs)byName.push(candidateFrom('contact', doc.id, doc.data()));
144
+ for (const doc of leadNames.docs)byName.push(candidateFrom('lead', doc.id, doc.data()));
145
+ }
146
+ return selectPersonMatches({
147
+ request,
148
+ byEmail,
149
+ byName
150
+ });
151
+ }
152
+
153
+ //# sourceMappingURL=person-matches.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/person-matches.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 { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport type {\n PluginPersonMatch,\n PluginPersonMatchRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { crmRoutes } from '../model/crm-routes'\nimport { namesResemble } from '../model/person-name-match'\n\n/**\n * The CRM's answer to \"did this workspace already know this person?\"\n * (AGL-3289), registered through the platform's person-match seam and asked\n * when staff read where an account came from.\n *\n * Contacts and leads are both searched: by the account's address exactly —\n * the contact through its email index, the lead by its person key — and by\n * the account's name, as a resemblance the card shows as \"possible\".\n *\n * The account's OWN record is left out. The platform files every new account\n * into its sales workspace as a contact whose only source is `account`, so a\n * record like that, first seen once the account existed, is the sign-up\n * itself and says nothing about whether anybody knew them before.\n */\n\n/** How many records each collection's name scan reads, at most. */\nexport const PERSON_NAME_SCAN_LIMIT = 5000\n/** How many name resemblances are returned, at most. */\nexport const PERSON_NAME_MATCH_LIMIT = 5\n/** Slack between the account's creation and its own record being written. */\nconst OWN_RECORD_SLACK_MS = 10 * 60 * 1000\n\n/** A contact or lead as the matcher reads it. */\nexport interface PersonCandidate {\n kind: 'contact' | 'lead'\n id: string\n name: string | null\n email: string | null\n sources: string[]\n firstSeenAtMs: number | null\n}\n\nfunction millis(value: unknown): number | null {\n if (typeof value === 'number' && value > 0) return value\n const timestamp = value as { toMillis?: () => number } | null\n return timestamp && typeof timestamp.toMillis === 'function' ? timestamp.toMillis() : null\n}\n\nfunction sourcesOf(value: unknown): string[] {\n if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === 'string')\n if (value && typeof value === 'object') {\n return Object.entries(value as Record<string, unknown>)\n .filter(([, flag]) => flag === true)\n .map(([source]) => source)\n }\n return []\n}\n\n/** A contact or lead document as a candidate. */\nexport function candidateFrom(\n kind: PersonCandidate['kind'],\n id: string,\n data: Record<string, unknown> | undefined,\n): PersonCandidate {\n return {\n kind,\n id,\n name: typeof data?.['name'] === 'string' ? (data['name'] as string) : null,\n email: normalizeContactEmail(data?.['email']) || null,\n sources: sourcesOf(data?.['sources']),\n firstSeenAtMs: millis(data?.['firstSeenAtMs']) ?? millis(data?.['createdAt']),\n }\n}\n\n/** Whether a record is the one the account's own sign-up made. */\nexport function isTheAccountsOwnRecord(\n candidate: PersonCandidate,\n accountCreatedAtMs: number | null,\n): boolean {\n if (candidate.sources.length !== 1 || candidate.sources[0] !== 'account') return false\n if (accountCreatedAtMs === null || candidate.firstSeenAtMs === null) return true\n return candidate.firstSeenAtMs >= accountCreatedAtMs - OWN_RECORD_SLACK_MS\n}\n\n/**\n * What to report, from what was read: every address match that is not the\n * account itself, then up to {@link PERSON_NAME_MATCH_LIMIT} name\n * resemblances that are neither an address match nor the account itself.\n */\nexport function selectPersonMatches(input: {\n request: PluginPersonMatchRequest\n byEmail: readonly PersonCandidate[]\n byName: readonly PersonCandidate[]\n}): PluginPersonMatch[] {\n const { request } = input\n const email = normalizeContactEmail(request.email) || null\n const hub = request.orgSlug\n ? crmRoutes(buildRoute(Route.ORG_PLUGIN, { orgSlug: request.orgSlug, pluginSlug: 'crm' }))\n : null\n const href = (candidate: PersonCandidate): string | null =>\n hub ? (candidate.kind === 'contact' ? hub.contact(candidate.id) : hub.section('leads')) : null\n const toMatch = (candidate: PersonCandidate, basis: 'email' | 'name'): PluginPersonMatch => ({\n kind: candidate.kind,\n id: candidate.id,\n label: candidate.name || candidate.email || candidate.id,\n email: candidate.email,\n basis,\n firstSeenAtMs: candidate.firstSeenAtMs,\n sources: candidate.sources,\n href: href(candidate),\n })\n\n const seen = new Set<string>()\n const matches: PluginPersonMatch[] = []\n for (const candidate of input.byEmail) {\n const key = `${candidate.kind}:${candidate.id}`\n if (seen.has(key)) continue\n seen.add(key)\n if (isTheAccountsOwnRecord(candidate, request.accountCreatedAtMs)) continue\n matches.push(toMatch(candidate, 'email'))\n }\n let named = 0\n for (const candidate of input.byName) {\n if (named >= PERSON_NAME_MATCH_LIMIT) break\n const key = `${candidate.kind}:${candidate.id}`\n if (seen.has(key)) continue\n if (email && candidate.email === email) continue\n if (isTheAccountsOwnRecord(candidate, request.accountCreatedAtMs)) continue\n if (!namesResemble(request.name, candidate.name)) continue\n seen.add(key)\n matches.push(toMatch(candidate, 'name'))\n named++\n }\n return matches\n}\n\n/** The matcher the CRM registers. Reads only. */\nexport async function matchCrmPeople(\n request: PluginPersonMatchRequest,\n): Promise<PluginPersonMatch[]> {\n const firestore = firebaseAdmin.app().firestore()\n const org = firestore.collection('orgs').doc(request.orgId)\n const contacts = org.collection('contacts')\n const leads = org.collection('leads')\n const email = normalizeContactEmail(request.email)\n\n const byEmail: PersonCandidate[] = []\n const key = email ? personKey(email) : null\n if (email && key) {\n const [contact, lead, alternates] = await Promise.all([\n findContactByEmail(contacts, email),\n leads.doc(key).get(),\n contacts.where('alternateEmails', 'array-contains', email).limit(3).get(),\n ])\n if (contact) byEmail.push(candidateFrom('contact', contact.id, contact.data()))\n if (lead.exists) byEmail.push(candidateFrom('lead', lead.id, lead.data()))\n for (const doc of alternates.docs) byEmail.push(candidateFrom('contact', doc.id, doc.data()))\n }\n\n const byName: PersonCandidate[] = []\n if (request.name) {\n const fields = ['name', 'email', 'sources', 'createdAt', 'firstSeenAtMs']\n const [contactNames, leadNames] = await Promise.all([\n contacts.select(...fields).limit(PERSON_NAME_SCAN_LIMIT).get(),\n leads.select(...fields).limit(PERSON_NAME_SCAN_LIMIT).get(),\n ])\n for (const doc of contactNames.docs) byName.push(candidateFrom('contact', doc.id, doc.data()))\n for (const doc of leadNames.docs) byName.push(candidateFrom('lead', doc.id, doc.data()))\n }\n\n return selectPersonMatches({ request, byEmail, byName })\n}\n"],"names":["buildRoute","Route","normalizeContactEmail","personKey","firebaseAdmin","findContactByEmail","crmRoutes","namesResemble","PERSON_NAME_SCAN_LIMIT","PERSON_NAME_MATCH_LIMIT","OWN_RECORD_SLACK_MS","millis","value","timestamp","toMillis","sourcesOf","Array","isArray","filter","entry","Object","entries","flag","map","source","candidateFrom","kind","id","data","name","email","sources","firstSeenAtMs","isTheAccountsOwnRecord","candidate","accountCreatedAtMs","length","selectPersonMatches","input","request","hub","orgSlug","ORG_PLUGIN","pluginSlug","href","contact","section","toMatch","basis","label","seen","Set","matches","byEmail","key","has","add","push","named","byName","matchCrmPeople","firestore","app","org","collection","doc","orgId","contacts","leads","lead","alternates","Promise","all","get","where","limit","exists","docs","fields","contactNames","leadNames","select"],"mappings":"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"}