@agent-native/core 0.123.0 → 0.123.1
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +16 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/templates-meta.ts +12 -0
- package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
- package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
- package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
- package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
- package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
- package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
- package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
- package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
- package/corpus/templates/crm/.env.example +5 -0
- package/corpus/templates/crm/AGENTS.md +82 -0
- package/corpus/templates/crm/CHANGELOG.md +25 -0
- package/corpus/templates/crm/DEVELOPING.md +66 -0
- package/corpus/templates/crm/README.md +122 -0
- package/corpus/templates/crm/_gitignore +12 -0
- package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
- package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
- package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
- package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
- package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
- package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
- package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
- package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
- package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
- package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
- package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
- package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
- package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
- package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
- package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
- package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
- package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
- package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
- package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
- package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
- package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
- package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
- package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
- package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
- package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
- package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
- package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
- package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
- package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
- package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
- package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
- package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
- package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
- package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
- package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
- package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
- package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
- package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
- package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
- package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
- package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
- package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
- package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
- package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
- package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
- package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
- package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
- package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
- package/corpus/templates/crm/actions/navigate.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
- package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
- package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
- package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
- package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
- package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
- package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
- package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
- package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
- package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
- package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
- package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
- package/corpus/templates/crm/actions/run.ts +7 -0
- package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
- package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
- package/corpus/templates/crm/actions/sync-crm.ts +146 -0
- package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
- package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
- package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
- package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
- package/corpus/templates/crm/actions/view-screen.ts +324 -0
- package/corpus/templates/crm/agent-native.app-skill.json +69 -0
- package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
- package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
- package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
- package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
- package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
- package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
- package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
- package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
- package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
- package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
- package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
- package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
- package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
- package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
- package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
- package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
- package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
- package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
- package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
- package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
- package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
- package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
- package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
- package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
- package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
- package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
- package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
- package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
- package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
- package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
- package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
- package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
- package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
- package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
- package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
- package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
- package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
- package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
- package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
- package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
- package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
- package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
- package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
- package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
- package/corpus/templates/crm/app/design-system.ts +3 -0
- package/corpus/templates/crm/app/entry.client.tsx +19 -0
- package/corpus/templates/crm/app/entry.server.tsx +10 -0
- package/corpus/templates/crm/app/global.css +167 -0
- package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
- package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
- package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
- package/corpus/templates/crm/app/i18n/index.ts +34 -0
- package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
- package/corpus/templates/crm/app/lib/navigation.ts +17 -0
- package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
- package/corpus/templates/crm/app/lib/types.ts +172 -0
- package/corpus/templates/crm/app/lib/utils.ts +1 -0
- package/corpus/templates/crm/app/root.tsx +93 -0
- package/corpus/templates/crm/app/routes/_index.tsx +26 -0
- package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
- package/corpus/templates/crm/app/routes/agent.tsx +5 -0
- package/corpus/templates/crm/app/routes/ask.tsx +53 -0
- package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
- package/corpus/templates/crm/app/routes/lists.tsx +228 -0
- package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
- package/corpus/templates/crm/app/routes/people.tsx +41 -0
- package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
- package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
- package/corpus/templates/crm/app/routes/records.tsx +64 -0
- package/corpus/templates/crm/app/routes/settings.tsx +118 -0
- package/corpus/templates/crm/app/routes/setup.tsx +308 -0
- package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
- package/corpus/templates/crm/app/routes/views.tsx +683 -0
- package/corpus/templates/crm/app/routes.ts +19 -0
- package/corpus/templates/crm/app/vite-env.d.ts +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-any-list-or-view-can-be-shown-as-a-board-grouped-by-stage-so.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-press-cmd-k-to-jump-to-any-record-list-view-or-setting-and-u.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
- package/corpus/templates/crm/components.json +20 -0
- package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
- package/corpus/templates/crm/docs/features/README.md +55 -0
- package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
- package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
- package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
- package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
- package/corpus/templates/crm/learnings.defaults.md +5 -0
- package/corpus/templates/crm/netlify.toml +11 -0
- package/corpus/templates/crm/package.json +61 -0
- package/corpus/templates/crm/public/favicon.svg +1 -0
- package/corpus/templates/crm/public/icon-180.svg +1 -0
- package/corpus/templates/crm/public/manifest.json +11 -0
- package/corpus/templates/crm/react-router.config.ts +7 -0
- package/corpus/templates/crm/server/crm/adapter.ts +32 -0
- package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
- package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
- package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
- package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
- package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
- package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
- package/corpus/templates/crm/server/crm/read-through.ts +207 -0
- package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
- package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
- package/corpus/templates/crm/server/db/index.ts +191 -0
- package/corpus/templates/crm/server/db/schema.ts +647 -0
- package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
- package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
- package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
- package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
- package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
- package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
- package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
- package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
- package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
- package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
- package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
- package/corpus/templates/crm/server/middleware/auth.ts +4 -0
- package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
- package/corpus/templates/crm/server/plugins/auth.ts +14 -0
- package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
- package/corpus/templates/crm/server/plugins/db.ts +738 -0
- package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
- package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
- package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
- package/corpus/templates/crm/shared/crm-contract.ts +417 -0
- package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
- package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
- package/corpus/templates/crm/ssr-entry.ts +11 -0
- package/corpus/templates/crm/tsconfig.json +24 -0
- package/corpus/templates/crm/vite.config.ts +15 -0
- package/corpus/templates/crm/vitest.config.ts +5 -0
- package/corpus/templates/design/app/i18n-data.ts +12 -12
- package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +12 -0
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/cli/templates-meta.ts +12 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duplicate-candidate detection over the sparse sub-field columns.
|
|
3
|
+
*
|
|
4
|
+
* Every predicate here reads an INDEXABLE column that the attribute writer
|
|
5
|
+
* already populated (`email_local`/`email_domain`, `email_root_domain`,
|
|
6
|
+
* `domain_root`, `name_first`/`name_last`) rather than pattern-matching a JSON
|
|
7
|
+
* blob at query time — `json_extract` in a WHERE clause is dialect-divergent,
|
|
8
|
+
* and a full scan over `crm_record_fields` is not something a grid can afford.
|
|
9
|
+
*
|
|
10
|
+
* Detection NEVER merges. It returns scored candidates with the reason each one
|
|
11
|
+
* matched, because a duplicate the reviewer cannot see the reason for is a
|
|
12
|
+
* duplicate the reviewer cannot act on — and a "known contact" silently dropped
|
|
13
|
+
* at the end of a workflow is exactly the failure this module exists to avoid.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { accessFilter } from "@agent-native/core/sharing";
|
|
17
|
+
import { and, eq, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm";
|
|
18
|
+
|
|
19
|
+
import { parseEmail, rootDomainOf } from "../../shared/crm-attributes.js";
|
|
20
|
+
import { schema } from "../db/index.js";
|
|
21
|
+
import type { CrmFieldWriteDb } from "./record-fields.js";
|
|
22
|
+
|
|
23
|
+
export const CRM_DUPLICATE_MATCH_REASONS = [
|
|
24
|
+
"email",
|
|
25
|
+
"domain",
|
|
26
|
+
"name-and-location",
|
|
27
|
+
"email-root-domain",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type CrmDuplicateMatchReason =
|
|
31
|
+
(typeof CRM_DUPLICATE_MATCH_REASONS)[number];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-reason confidence, combined with noisy-OR so two weak signals add up to
|
|
35
|
+
* more than either alone without ever reaching certainty.
|
|
36
|
+
*
|
|
37
|
+
* `email` is near-certain because a mailbox is one person. `domain` is strong
|
|
38
|
+
* for companies and deliberately never applied to people (colleagues share a
|
|
39
|
+
* company domain — that is a relationship, not a duplicate). `email-root-domain`
|
|
40
|
+
* is a hint only, for the same reason.
|
|
41
|
+
*/
|
|
42
|
+
const REASON_CONFIDENCE: Record<CrmDuplicateMatchReason, number> = {
|
|
43
|
+
email: 0.95,
|
|
44
|
+
domain: 0.7,
|
|
45
|
+
"name-and-location": 0.6,
|
|
46
|
+
"email-root-domain": 0.2,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Domains that identify a mailbox provider rather than an organization. Without
|
|
51
|
+
* this list every personal Gmail contact is a "duplicate" of every other.
|
|
52
|
+
*
|
|
53
|
+
* ponytail: hand-list, not a public mailbox-provider dataset. Symptom of a gap
|
|
54
|
+
* is a low-confidence pile of `email-root-domain` candidates sharing one host;
|
|
55
|
+
* add the host here rather than lowering the weight.
|
|
56
|
+
*/
|
|
57
|
+
const CONSUMER_EMAIL_DOMAINS = new Set([
|
|
58
|
+
"gmail.com",
|
|
59
|
+
"googlemail.com",
|
|
60
|
+
"outlook.com",
|
|
61
|
+
"hotmail.com",
|
|
62
|
+
"live.com",
|
|
63
|
+
"msn.com",
|
|
64
|
+
"yahoo.com",
|
|
65
|
+
"ymail.com",
|
|
66
|
+
"icloud.com",
|
|
67
|
+
"me.com",
|
|
68
|
+
"mac.com",
|
|
69
|
+
"aol.com",
|
|
70
|
+
"proton.me",
|
|
71
|
+
"protonmail.com",
|
|
72
|
+
"gmx.com",
|
|
73
|
+
"gmx.de",
|
|
74
|
+
"mail.com",
|
|
75
|
+
"zoho.com",
|
|
76
|
+
"yandex.ru",
|
|
77
|
+
"qq.com",
|
|
78
|
+
"163.com",
|
|
79
|
+
"126.com",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Legal-form suffixes that carry no identity — "Acme Inc" is "Acme". */
|
|
83
|
+
const COMPANY_SUFFIXES = new Set([
|
|
84
|
+
"inc",
|
|
85
|
+
"incorporated",
|
|
86
|
+
"llc",
|
|
87
|
+
"llp",
|
|
88
|
+
"ltd",
|
|
89
|
+
"limited",
|
|
90
|
+
"plc",
|
|
91
|
+
"corp",
|
|
92
|
+
"corporation",
|
|
93
|
+
"co",
|
|
94
|
+
"company",
|
|
95
|
+
"gmbh",
|
|
96
|
+
"ag",
|
|
97
|
+
"sa",
|
|
98
|
+
"sas",
|
|
99
|
+
"bv",
|
|
100
|
+
"nv",
|
|
101
|
+
"ab",
|
|
102
|
+
"oy",
|
|
103
|
+
"as",
|
|
104
|
+
"pty",
|
|
105
|
+
"pte",
|
|
106
|
+
"srl",
|
|
107
|
+
"spa",
|
|
108
|
+
"kk",
|
|
109
|
+
"holdings",
|
|
110
|
+
"group",
|
|
111
|
+
"the",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/** Rows scanned per match class. Detection is a bounded probe, never a sweep. */
|
|
115
|
+
export const MAX_DEDUPE_SCAN = 500;
|
|
116
|
+
|
|
117
|
+
export interface CrmDuplicateSignal {
|
|
118
|
+
reason: CrmDuplicateMatchReason;
|
|
119
|
+
/** The shared value that matched, so a reviewer can see WHY. */
|
|
120
|
+
value: string;
|
|
121
|
+
confidence: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface CrmDuplicateCandidate {
|
|
125
|
+
recordId: string;
|
|
126
|
+
displayName: string;
|
|
127
|
+
objectType: string;
|
|
128
|
+
kind: string;
|
|
129
|
+
connectionId: string;
|
|
130
|
+
signals: CrmDuplicateSignal[];
|
|
131
|
+
confidence: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface CrmDuplicateSeed {
|
|
135
|
+
recordId: string;
|
|
136
|
+
displayName: string;
|
|
137
|
+
objectType: string;
|
|
138
|
+
kind: string;
|
|
139
|
+
connectionId: string;
|
|
140
|
+
candidates: CrmDuplicateCandidate[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface SeedRow {
|
|
144
|
+
id: string;
|
|
145
|
+
displayName: string;
|
|
146
|
+
objectType: string;
|
|
147
|
+
kind: string;
|
|
148
|
+
connectionId: string;
|
|
149
|
+
primaryEmail: string | null;
|
|
150
|
+
domain: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface SeedKeys {
|
|
154
|
+
/** Full normalized addresses, e.g. `ada@example.com`. */
|
|
155
|
+
emails: Set<string>;
|
|
156
|
+
emailRootDomains: Set<string>;
|
|
157
|
+
domainRoots: Set<string>;
|
|
158
|
+
normalizedName: string | null;
|
|
159
|
+
/** Canonical location string, or null when the record declares none. */
|
|
160
|
+
location: string | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Comparable form of a display name: diacritics folded, punctuation dropped,
|
|
165
|
+
* legal-form suffixes removed. This IS the fuzz — trigram similarity is not
|
|
166
|
+
* expressible in dialect-agnostic SQL, so candidates are narrowed by an indexed
|
|
167
|
+
* prefix and compared here.
|
|
168
|
+
*/
|
|
169
|
+
export function normalizeCrmDisplayName(value: string): string {
|
|
170
|
+
const words = value
|
|
171
|
+
.normalize("NFKD")
|
|
172
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
175
|
+
.trim()
|
|
176
|
+
.split(" ")
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.filter((word) => !COMPANY_SUFFIXES.has(word));
|
|
179
|
+
return words.join(" ");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Canonical location key. `null` when nothing usable was declared. */
|
|
183
|
+
export function normalizeCrmLocation(value: unknown): string | null {
|
|
184
|
+
if (typeof value === "string") {
|
|
185
|
+
const normalized = normalizeCrmDisplayName(value);
|
|
186
|
+
return normalized || null;
|
|
187
|
+
}
|
|
188
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
189
|
+
const record = value as Record<string, unknown>;
|
|
190
|
+
const parts = ["locality", "city", "region", "state", "country"]
|
|
191
|
+
.map((key) => record[key])
|
|
192
|
+
.filter((part): part is string => typeof part === "string" && !!part.trim())
|
|
193
|
+
.map((part) => normalizeCrmDisplayName(part))
|
|
194
|
+
.filter(Boolean);
|
|
195
|
+
return parts.length ? parts.join(" ") : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isOrganizationKind(kind: string): boolean {
|
|
199
|
+
return kind === "account";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function loadSeedRows(
|
|
203
|
+
db: CrmFieldWriteDb,
|
|
204
|
+
recordIds: string[],
|
|
205
|
+
): Promise<SeedRow[]> {
|
|
206
|
+
return db
|
|
207
|
+
.select({
|
|
208
|
+
id: schema.crmRecords.id,
|
|
209
|
+
displayName: schema.crmRecords.displayName,
|
|
210
|
+
objectType: schema.crmRecords.objectType,
|
|
211
|
+
kind: schema.crmRecords.kind,
|
|
212
|
+
connectionId: schema.crmRecords.connectionId,
|
|
213
|
+
primaryEmail: schema.crmRecords.primaryEmail,
|
|
214
|
+
domain: schema.crmRecords.domain,
|
|
215
|
+
})
|
|
216
|
+
.from(schema.crmRecords)
|
|
217
|
+
.where(
|
|
218
|
+
and(
|
|
219
|
+
inArray(schema.crmRecords.id, recordIds),
|
|
220
|
+
eq(schema.crmRecords.tombstone, false),
|
|
221
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Current sub-field values for a set of records, one query. */
|
|
227
|
+
async function loadIdentityFields(
|
|
228
|
+
db: CrmFieldWriteDb,
|
|
229
|
+
recordIds: string[],
|
|
230
|
+
): Promise<
|
|
231
|
+
Array<{
|
|
232
|
+
recordId: string;
|
|
233
|
+
emailLocal: string | null;
|
|
234
|
+
emailDomain: string | null;
|
|
235
|
+
emailRootDomain: string | null;
|
|
236
|
+
domainRoot: string | null;
|
|
237
|
+
jsonValue: string | null;
|
|
238
|
+
valueType: string;
|
|
239
|
+
}>
|
|
240
|
+
> {
|
|
241
|
+
if (!recordIds.length) return [];
|
|
242
|
+
return db
|
|
243
|
+
.select({
|
|
244
|
+
recordId: schema.crmRecordFields.recordId,
|
|
245
|
+
emailLocal: schema.crmRecordFields.emailLocal,
|
|
246
|
+
emailDomain: schema.crmRecordFields.emailDomain,
|
|
247
|
+
emailRootDomain: schema.crmRecordFields.emailRootDomain,
|
|
248
|
+
domainRoot: schema.crmRecordFields.domainRoot,
|
|
249
|
+
jsonValue: schema.crmRecordFields.jsonValue,
|
|
250
|
+
valueType: schema.crmRecordFields.valueType,
|
|
251
|
+
})
|
|
252
|
+
.from(schema.crmRecordFields)
|
|
253
|
+
.where(
|
|
254
|
+
and(
|
|
255
|
+
inArray(schema.crmRecordFields.recordId, recordIds),
|
|
256
|
+
isNull(schema.crmRecordFields.entryId),
|
|
257
|
+
isNull(schema.crmRecordFields.activeUntil),
|
|
258
|
+
accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
.limit(MAX_DEDUPE_SCAN);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function seedKeysFor(
|
|
265
|
+
row: SeedRow,
|
|
266
|
+
fields: Array<{
|
|
267
|
+
emailLocal: string | null;
|
|
268
|
+
emailDomain: string | null;
|
|
269
|
+
emailRootDomain: string | null;
|
|
270
|
+
domainRoot: string | null;
|
|
271
|
+
jsonValue: string | null;
|
|
272
|
+
valueType: string;
|
|
273
|
+
}>,
|
|
274
|
+
): SeedKeys {
|
|
275
|
+
const keys: SeedKeys = {
|
|
276
|
+
emails: new Set(),
|
|
277
|
+
emailRootDomains: new Set(),
|
|
278
|
+
domainRoots: new Set(),
|
|
279
|
+
normalizedName: normalizeCrmDisplayName(row.displayName) || null,
|
|
280
|
+
location: null,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const addEmail = (local: string | null, domain: string | null) => {
|
|
284
|
+
if (!local || !domain) return;
|
|
285
|
+
keys.emails.add(`${local}@${domain}`);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const mirrored = parseEmail(row.primaryEmail);
|
|
289
|
+
if (mirrored.status === "parsed") {
|
|
290
|
+
addEmail(mirrored.local, mirrored.domain);
|
|
291
|
+
if (!CONSUMER_EMAIL_DOMAINS.has(mirrored.rootDomain)) {
|
|
292
|
+
keys.emailRootDomains.add(mirrored.rootDomain);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const mirroredDomain = rootDomainOf(row.domain);
|
|
296
|
+
if (mirroredDomain) keys.domainRoots.add(mirroredDomain);
|
|
297
|
+
|
|
298
|
+
for (const field of fields) {
|
|
299
|
+
addEmail(field.emailLocal, field.emailDomain);
|
|
300
|
+
if (
|
|
301
|
+
field.emailRootDomain &&
|
|
302
|
+
!CONSUMER_EMAIL_DOMAINS.has(field.emailRootDomain)
|
|
303
|
+
) {
|
|
304
|
+
keys.emailRootDomains.add(field.emailRootDomain);
|
|
305
|
+
}
|
|
306
|
+
if (field.domainRoot) keys.domainRoots.add(field.domainRoot);
|
|
307
|
+
if (field.valueType === "json" && field.jsonValue && !keys.location) {
|
|
308
|
+
try {
|
|
309
|
+
keys.location = normalizeCrmLocation(JSON.parse(field.jsonValue));
|
|
310
|
+
} catch {
|
|
311
|
+
// A value the location parser cannot read is not a location match and
|
|
312
|
+
// is not silently treated as one; it simply contributes no key.
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A consumer mailbox host is a company domain for nobody.
|
|
318
|
+
for (const domain of [...keys.domainRoots]) {
|
|
319
|
+
if (CONSUMER_EMAIL_DOMAINS.has(domain)) keys.domainRoots.delete(domain);
|
|
320
|
+
}
|
|
321
|
+
return keys;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface CandidateAccumulator {
|
|
325
|
+
signals: Map<CrmDuplicateMatchReason, CrmDuplicateSignal>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function addSignal(
|
|
329
|
+
bucket: Map<string, CandidateAccumulator>,
|
|
330
|
+
recordId: string,
|
|
331
|
+
signal: CrmDuplicateSignal,
|
|
332
|
+
): void {
|
|
333
|
+
const existing = bucket.get(recordId) ?? { signals: new Map() };
|
|
334
|
+
const prior = existing.signals.get(signal.reason);
|
|
335
|
+
if (!prior || prior.confidence < signal.confidence) {
|
|
336
|
+
existing.signals.set(signal.reason, signal);
|
|
337
|
+
}
|
|
338
|
+
bucket.set(recordId, existing);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Noisy-OR: independent weak signals accumulate but never reach certainty. */
|
|
342
|
+
function combineConfidence(signals: CrmDuplicateSignal[]): number {
|
|
343
|
+
const combined = signals.reduce(
|
|
344
|
+
(miss, signal) => miss * (1 - signal.confidence),
|
|
345
|
+
1,
|
|
346
|
+
);
|
|
347
|
+
return Math.round((1 - combined) * 1000) / 1000;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface FindCrmDuplicatesInput {
|
|
351
|
+
db: CrmFieldWriteDb;
|
|
352
|
+
recordIds: string[];
|
|
353
|
+
/** Candidates below this are not returned at all. */
|
|
354
|
+
minConfidence?: number;
|
|
355
|
+
/** Candidates per seed. */
|
|
356
|
+
limit?: number;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Scored duplicate candidates for each seed record, newest signal wins per
|
|
361
|
+
* reason. Seeds the caller cannot read are absent from the result — an empty
|
|
362
|
+
* candidate list means "checked, nothing matched", never "could not check".
|
|
363
|
+
*/
|
|
364
|
+
export async function findCrmDuplicateCandidates(
|
|
365
|
+
input: FindCrmDuplicatesInput,
|
|
366
|
+
): Promise<CrmDuplicateSeed[]> {
|
|
367
|
+
const limit = input.limit ?? 10;
|
|
368
|
+
const minConfidence = input.minConfidence ?? 0.2;
|
|
369
|
+
const seedRows = await loadSeedRows(input.db, input.recordIds);
|
|
370
|
+
if (!seedRows.length) return [];
|
|
371
|
+
|
|
372
|
+
const identityFields = await loadIdentityFields(
|
|
373
|
+
input.db,
|
|
374
|
+
seedRows.map((row) => row.id),
|
|
375
|
+
);
|
|
376
|
+
const fieldsByRecord = new Map<string, typeof identityFields>();
|
|
377
|
+
for (const field of identityFields) {
|
|
378
|
+
const bucket = fieldsByRecord.get(field.recordId) ?? [];
|
|
379
|
+
bucket.push(field);
|
|
380
|
+
fieldsByRecord.set(field.recordId, bucket);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const seeds: CrmDuplicateSeed[] = [];
|
|
384
|
+
for (const row of seedRows) {
|
|
385
|
+
const keys = seedKeysFor(row, fieldsByRecord.get(row.id) ?? []);
|
|
386
|
+
const bucket = new Map<string, CandidateAccumulator>();
|
|
387
|
+
|
|
388
|
+
await matchEmails(input.db, row, keys, bucket);
|
|
389
|
+
await matchDomains(input.db, row, keys, bucket);
|
|
390
|
+
await matchNameAndLocation(input.db, row, keys, bucket);
|
|
391
|
+
|
|
392
|
+
const candidateIds = [...bucket.keys()];
|
|
393
|
+
const candidateRows = candidateIds.length
|
|
394
|
+
? await input.db
|
|
395
|
+
.select({
|
|
396
|
+
id: schema.crmRecords.id,
|
|
397
|
+
displayName: schema.crmRecords.displayName,
|
|
398
|
+
objectType: schema.crmRecords.objectType,
|
|
399
|
+
kind: schema.crmRecords.kind,
|
|
400
|
+
connectionId: schema.crmRecords.connectionId,
|
|
401
|
+
})
|
|
402
|
+
.from(schema.crmRecords)
|
|
403
|
+
.where(
|
|
404
|
+
and(
|
|
405
|
+
inArray(schema.crmRecords.id, candidateIds),
|
|
406
|
+
eq(schema.crmRecords.tombstone, false),
|
|
407
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
: [];
|
|
411
|
+
|
|
412
|
+
const candidates: CrmDuplicateCandidate[] = [];
|
|
413
|
+
for (const candidate of candidateRows) {
|
|
414
|
+
const signals = [...(bucket.get(candidate.id)?.signals.values() ?? [])];
|
|
415
|
+
const confidence = combineConfidence(signals);
|
|
416
|
+
if (confidence < minConfidence) continue;
|
|
417
|
+
candidates.push({
|
|
418
|
+
recordId: candidate.id,
|
|
419
|
+
displayName: candidate.displayName,
|
|
420
|
+
objectType: candidate.objectType,
|
|
421
|
+
kind: candidate.kind,
|
|
422
|
+
connectionId: candidate.connectionId,
|
|
423
|
+
signals: signals.sort((a, b) => b.confidence - a.confidence),
|
|
424
|
+
confidence,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
candidates.sort(
|
|
428
|
+
(a, b) =>
|
|
429
|
+
b.confidence - a.confidence || a.recordId.localeCompare(b.recordId),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
seeds.push({
|
|
433
|
+
recordId: row.id,
|
|
434
|
+
displayName: row.displayName,
|
|
435
|
+
objectType: row.objectType,
|
|
436
|
+
kind: row.kind,
|
|
437
|
+
connectionId: row.connectionId,
|
|
438
|
+
candidates: candidates.slice(0, limit),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return seeds;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Records of the same object type that are not the seed and not tombstoned.
|
|
446
|
+
*
|
|
447
|
+
* Access scoping is deliberately NOT folded in here: every caller spells its own
|
|
448
|
+
* `accessFilter` out at the query, because a scan that leaks other people's
|
|
449
|
+
* records is exactly the bug this shape would hide behind one helper name.
|
|
450
|
+
*/
|
|
451
|
+
function comparableRecords(seed: SeedRow): SQL {
|
|
452
|
+
return and(
|
|
453
|
+
eq(schema.crmRecords.objectType, seed.objectType),
|
|
454
|
+
ne(schema.crmRecords.id, seed.id),
|
|
455
|
+
eq(schema.crmRecords.tombstone, false),
|
|
456
|
+
) as SQL;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function matchEmails(
|
|
460
|
+
db: CrmFieldWriteDb,
|
|
461
|
+
seed: SeedRow,
|
|
462
|
+
keys: SeedKeys,
|
|
463
|
+
bucket: Map<string, CandidateAccumulator>,
|
|
464
|
+
): Promise<void> {
|
|
465
|
+
if (!keys.emails.size && !keys.emailRootDomains.size) return;
|
|
466
|
+
|
|
467
|
+
const emailPairs = [...keys.emails].map((email) => {
|
|
468
|
+
const at = email.lastIndexOf("@");
|
|
469
|
+
return { local: email.slice(0, at), domain: email.slice(at + 1) };
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const shapes: SQL[] = [];
|
|
473
|
+
if (emailPairs.length) {
|
|
474
|
+
shapes.push(
|
|
475
|
+
or(
|
|
476
|
+
...emailPairs.map(
|
|
477
|
+
(pair) =>
|
|
478
|
+
and(
|
|
479
|
+
eq(schema.crmRecordFields.emailLocal, pair.local),
|
|
480
|
+
eq(schema.crmRecordFields.emailDomain, pair.domain),
|
|
481
|
+
) as SQL,
|
|
482
|
+
),
|
|
483
|
+
) as SQL,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (keys.emailRootDomains.size) {
|
|
487
|
+
shapes.push(
|
|
488
|
+
inArray(schema.crmRecordFields.emailRootDomain, [
|
|
489
|
+
...keys.emailRootDomains,
|
|
490
|
+
]) as SQL,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const rows = await db
|
|
495
|
+
.select({
|
|
496
|
+
recordId: schema.crmRecordFields.recordId,
|
|
497
|
+
emailLocal: schema.crmRecordFields.emailLocal,
|
|
498
|
+
emailDomain: schema.crmRecordFields.emailDomain,
|
|
499
|
+
emailRootDomain: schema.crmRecordFields.emailRootDomain,
|
|
500
|
+
})
|
|
501
|
+
.from(schema.crmRecordFields)
|
|
502
|
+
.innerJoin(
|
|
503
|
+
schema.crmRecords,
|
|
504
|
+
eq(schema.crmRecords.id, schema.crmRecordFields.recordId),
|
|
505
|
+
)
|
|
506
|
+
.where(
|
|
507
|
+
and(
|
|
508
|
+
or(...shapes),
|
|
509
|
+
isNull(schema.crmRecordFields.entryId),
|
|
510
|
+
isNull(schema.crmRecordFields.activeUntil),
|
|
511
|
+
accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
|
|
512
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
513
|
+
comparableRecords(seed),
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
.limit(MAX_DEDUPE_SCAN);
|
|
517
|
+
|
|
518
|
+
for (const row of rows) {
|
|
519
|
+
const email =
|
|
520
|
+
row.emailLocal && row.emailDomain
|
|
521
|
+
? `${row.emailLocal}@${row.emailDomain}`
|
|
522
|
+
: null;
|
|
523
|
+
if (email && keys.emails.has(email)) {
|
|
524
|
+
addSignal(bucket, row.recordId, {
|
|
525
|
+
reason: "email",
|
|
526
|
+
value: email,
|
|
527
|
+
confidence: REASON_CONFIDENCE.email,
|
|
528
|
+
});
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (
|
|
532
|
+
row.emailRootDomain &&
|
|
533
|
+
keys.emailRootDomains.has(row.emailRootDomain) &&
|
|
534
|
+
!isOrganizationKind(seed.kind)
|
|
535
|
+
) {
|
|
536
|
+
addSignal(bucket, row.recordId, {
|
|
537
|
+
reason: "email-root-domain",
|
|
538
|
+
value: row.emailRootDomain,
|
|
539
|
+
confidence: REASON_CONFIDENCE["email-root-domain"],
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The denormalized mirror column carries an address for records whose email
|
|
545
|
+
// attribute was never mirrored; skipping it would miss the obvious duplicate.
|
|
546
|
+
if (keys.emails.size) {
|
|
547
|
+
const mirrorRows = await db
|
|
548
|
+
.select({
|
|
549
|
+
id: schema.crmRecords.id,
|
|
550
|
+
primaryEmail: schema.crmRecords.primaryEmail,
|
|
551
|
+
})
|
|
552
|
+
.from(schema.crmRecords)
|
|
553
|
+
.where(
|
|
554
|
+
and(
|
|
555
|
+
inArray(sql`lower(${schema.crmRecords.primaryEmail})`, [
|
|
556
|
+
...keys.emails,
|
|
557
|
+
]),
|
|
558
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
559
|
+
comparableRecords(seed),
|
|
560
|
+
),
|
|
561
|
+
)
|
|
562
|
+
.limit(MAX_DEDUPE_SCAN);
|
|
563
|
+
for (const row of mirrorRows) {
|
|
564
|
+
const parsed = parseEmail(row.primaryEmail);
|
|
565
|
+
if (parsed.status !== "parsed") continue;
|
|
566
|
+
addSignal(bucket, row.id, {
|
|
567
|
+
reason: "email",
|
|
568
|
+
value: `${parsed.local}@${parsed.domain}`,
|
|
569
|
+
confidence: REASON_CONFIDENCE.email,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function matchDomains(
|
|
576
|
+
db: CrmFieldWriteDb,
|
|
577
|
+
seed: SeedRow,
|
|
578
|
+
keys: SeedKeys,
|
|
579
|
+
bucket: Map<string, CandidateAccumulator>,
|
|
580
|
+
): Promise<void> {
|
|
581
|
+
// A shared company domain identifies an ORGANIZATION. Two people at the same
|
|
582
|
+
// domain are colleagues; reporting that as a duplicate is how a real contact
|
|
583
|
+
// gets merged away.
|
|
584
|
+
if (!keys.domainRoots.size || !isOrganizationKind(seed.kind)) return;
|
|
585
|
+
const roots = [...keys.domainRoots];
|
|
586
|
+
|
|
587
|
+
const fieldRows = await db
|
|
588
|
+
.select({
|
|
589
|
+
recordId: schema.crmRecordFields.recordId,
|
|
590
|
+
domainRoot: schema.crmRecordFields.domainRoot,
|
|
591
|
+
})
|
|
592
|
+
.from(schema.crmRecordFields)
|
|
593
|
+
.innerJoin(
|
|
594
|
+
schema.crmRecords,
|
|
595
|
+
eq(schema.crmRecords.id, schema.crmRecordFields.recordId),
|
|
596
|
+
)
|
|
597
|
+
.where(
|
|
598
|
+
and(
|
|
599
|
+
inArray(schema.crmRecordFields.domainRoot, roots),
|
|
600
|
+
isNull(schema.crmRecordFields.entryId),
|
|
601
|
+
isNull(schema.crmRecordFields.activeUntil),
|
|
602
|
+
accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
|
|
603
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
604
|
+
comparableRecords(seed),
|
|
605
|
+
),
|
|
606
|
+
)
|
|
607
|
+
.limit(MAX_DEDUPE_SCAN);
|
|
608
|
+
for (const row of fieldRows) {
|
|
609
|
+
if (!row.domainRoot) continue;
|
|
610
|
+
addSignal(bucket, row.recordId, {
|
|
611
|
+
reason: "domain",
|
|
612
|
+
value: row.domainRoot,
|
|
613
|
+
confidence: REASON_CONFIDENCE.domain,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const mirrorRows = await db
|
|
618
|
+
.select({ id: schema.crmRecords.id, domain: schema.crmRecords.domain })
|
|
619
|
+
.from(schema.crmRecords)
|
|
620
|
+
.where(
|
|
621
|
+
and(
|
|
622
|
+
inArray(sql`lower(${schema.crmRecords.domain})`, roots),
|
|
623
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
624
|
+
comparableRecords(seed),
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
.limit(MAX_DEDUPE_SCAN);
|
|
628
|
+
for (const row of mirrorRows) {
|
|
629
|
+
const root = rootDomainOf(row.domain);
|
|
630
|
+
if (!root) continue;
|
|
631
|
+
addSignal(bucket, row.id, {
|
|
632
|
+
reason: "domain",
|
|
633
|
+
value: root,
|
|
634
|
+
confidence: REASON_CONFIDENCE.domain,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function matchNameAndLocation(
|
|
640
|
+
db: CrmFieldWriteDb,
|
|
641
|
+
seed: SeedRow,
|
|
642
|
+
keys: SeedKeys,
|
|
643
|
+
bucket: Map<string, CandidateAccumulator>,
|
|
644
|
+
): Promise<void> {
|
|
645
|
+
if (!keys.normalizedName) return;
|
|
646
|
+
const firstWord = keys.normalizedName.split(" ")[0];
|
|
647
|
+
if (!firstWord) return;
|
|
648
|
+
|
|
649
|
+
// `lower(...)` on both sides: SQLite LIKE folds ASCII case, Postgres LIKE
|
|
650
|
+
// does not, and a match that differs by dialect is a bug.
|
|
651
|
+
//
|
|
652
|
+
// ponytail: a leading-wildcard LIKE cannot use an index, so this is a bounded
|
|
653
|
+
// scan capped at MAX_DEDUPE_SCAN rows of the same object type. The upgrade is
|
|
654
|
+
// a normalized-name column written by the attribute writer; add it when the
|
|
655
|
+
// record count makes this probe show up in a slow query log.
|
|
656
|
+
const rows = await db
|
|
657
|
+
.select({
|
|
658
|
+
id: schema.crmRecords.id,
|
|
659
|
+
displayName: schema.crmRecords.displayName,
|
|
660
|
+
})
|
|
661
|
+
.from(schema.crmRecords)
|
|
662
|
+
.where(
|
|
663
|
+
and(
|
|
664
|
+
sql`lower(${schema.crmRecords.displayName}) like ${`%${firstWord}%`}`,
|
|
665
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
666
|
+
comparableRecords(seed),
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
.limit(MAX_DEDUPE_SCAN);
|
|
670
|
+
if (!rows.length) return;
|
|
671
|
+
|
|
672
|
+
const nameMatches = rows.filter(
|
|
673
|
+
(row) => normalizeCrmDisplayName(row.displayName) === keys.normalizedName,
|
|
674
|
+
);
|
|
675
|
+
if (!nameMatches.length) return;
|
|
676
|
+
|
|
677
|
+
const locations = await loadIdentityFields(
|
|
678
|
+
db,
|
|
679
|
+
nameMatches.map((row) => row.id),
|
|
680
|
+
);
|
|
681
|
+
const locationByRecord = new Map<string, string | null>();
|
|
682
|
+
for (const field of locations) {
|
|
683
|
+
if (field.valueType !== "json" || !field.jsonValue) continue;
|
|
684
|
+
if (locationByRecord.has(field.recordId)) continue;
|
|
685
|
+
try {
|
|
686
|
+
locationByRecord.set(
|
|
687
|
+
field.recordId,
|
|
688
|
+
normalizeCrmLocation(JSON.parse(field.jsonValue)),
|
|
689
|
+
);
|
|
690
|
+
} catch {
|
|
691
|
+
locationByRecord.set(field.recordId, null);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
for (const row of nameMatches) {
|
|
696
|
+
// Name alone is not enough — two "John Smith" people are common. The pair
|
|
697
|
+
// only counts when the location agrees, or when neither side declares one
|
|
698
|
+
// for an organization (where the name itself is far more distinguishing).
|
|
699
|
+
const candidateLocation = locationByRecord.get(row.id) ?? null;
|
|
700
|
+
const agrees =
|
|
701
|
+
keys.location !== null && candidateLocation !== null
|
|
702
|
+
? keys.location === candidateLocation
|
|
703
|
+
: isOrganizationKind(seed.kind);
|
|
704
|
+
if (!agrees) continue;
|
|
705
|
+
addSignal(bucket, row.id, {
|
|
706
|
+
reason: "name-and-location",
|
|
707
|
+
value:
|
|
708
|
+
keys.location && candidateLocation
|
|
709
|
+
? `${keys.normalizedName} @ ${keys.location}`
|
|
710
|
+
: keys.normalizedName,
|
|
711
|
+
confidence: REASON_CONFIDENCE["name-and-location"],
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|