fullstackgtm 0.11.0 → 0.12.0
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/CHANGELOG.md +59 -0
- package/README.md +1 -1
- package/dist/connectors/hubspot.js +160 -11
- package/dist/connectors/salesforce.js +66 -9
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +1 -1
- package/dist/rules.js +23 -19
- package/dist/suggest.js +77 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +1 -1
- package/docs/crm-health-lifecycle.md +135 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/connectors/hubspot.ts +161 -11
- package/src/connectors/salesforce.ts +69 -9
- package/src/merge.ts +1 -1
- package/src/rules.ts +26 -19
- package/src/suggest.ts +88 -0
- package/src/types.ts +5 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# The CRM-health CRUD lifecycle: no new dupes
|
|
2
|
+
|
|
3
|
+
How fullstackgtm keeps a CRM healthy over time — not just episodically
|
|
4
|
+
audited. Grounded in dogfooding against a real portal (an outreach sync
|
|
5
|
+
tripled five open deals; our own `create:` path nearly minted a duplicate
|
|
6
|
+
company) and in what the platforms actually support today (verified 2026-06).
|
|
7
|
+
|
|
8
|
+
## The model: Prevent → Detect → Remediate → Verify/Attribute
|
|
9
|
+
|
|
10
|
+
Every mature dedupe stack (RingLead/ZoomInfo Ops, Insycle, Openprise,
|
|
11
|
+
Dedupely) converged on three layers, because each one leaks:
|
|
12
|
+
|
|
13
|
+
1. **Prevent** at write time — unique keys, upserts, point-of-entry gates.
|
|
14
|
+
Leaks because: HubSpot does not dedupe companies created via API at all,
|
|
15
|
+
deals have no native dedupe on any platform, and Salesforce duplicate
|
|
16
|
+
rules skip standard lead conversion.
|
|
17
|
+
2. **Detect** continuously — scheduled scans, because prevention leaked.
|
|
18
|
+
3. **Remediate** via survivor-rule-driven merge — irreversible on both
|
|
19
|
+
platforms, which is why preview/dry-run is table stakes.
|
|
20
|
+
|
|
21
|
+
fullstackgtm adds the layer the industry mostly lacks: **Verify/Attribute** —
|
|
22
|
+
typed, approved operations with compare-and-set, readback, drift diffs, and
|
|
23
|
+
record-source provenance that names *which writer* created the mess, so you
|
|
24
|
+
fix the faucet instead of mopping the puddle.
|
|
25
|
+
|
|
26
|
+
## C — Create: gate the faucet
|
|
27
|
+
|
|
28
|
+
**Platform facts to exploit before building anything:**
|
|
29
|
+
|
|
30
|
+
| | HubSpot | Salesforce |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Contacts | API create with existing email → **409 with existing ID** (a free find-or-create) | Duplicate Rules fire on API writes (`DUPLICATES_DETECTED`); Alert-action rules bypassable via `DuplicateRuleHeader`, Block never |
|
|
33
|
+
| Companies/Accounts | **No API dedupe** (UI/import domain-dedupe does not apply to API) | Same duplicate-rule machinery |
|
|
34
|
+
| Deals/Opps | **No native dedupe anywhere** | No standard duplicate rule shipped |
|
|
35
|
+
| Idempotent writes | `batch/upsert` keyed on unique-value custom properties (≤10/object) | Upsert by External ID field — explicitly idempotent |
|
|
36
|
+
| Leak paths | API company creates; anything without email | Standard lead conversion, Quick Create, undelete |
|
|
37
|
+
|
|
38
|
+
**Our primitives and their duties:**
|
|
39
|
+
- `create:<Name>` link values must be **resolve-first**: search the live CRM
|
|
40
|
+
(and the current plan run) for an existing record before creating; link to
|
|
41
|
+
a unique match, refuse on ambiguity, create only on a confirmed miss.
|
|
42
|
+
HubSpot's search API is eventually consistent (~5–10s), so same-run
|
|
43
|
+
creations are deduped in memory, not via search.
|
|
44
|
+
- A standalone **`resolve` gate** (planned, 0.13): given a candidate
|
|
45
|
+
record, return existing match(es) or "safe to create" — for the CLI, the
|
|
46
|
+
library, MCP, and any external writer (sync jobs, agents, webhook
|
|
47
|
+
handlers). Identity keys are the ones the package already uses:
|
|
48
|
+
contact email, normalized account domain, and the open-deal key
|
|
49
|
+
(account + normalized name).
|
|
50
|
+
- **Stamp provenance on our own creates** (HubSpot allows integrations to
|
|
51
|
+
set `hs_object_source_detail_2/3` at create time).
|
|
52
|
+
- Recommend native config in `doctor`/audit: Salesforce duplicate rules
|
|
53
|
+
active? HubSpot unique-value properties defined? Prevention posture is
|
|
54
|
+
auditable configuration, not just record state.
|
|
55
|
+
|
|
56
|
+
## R — Read: watch continuously, attribute the source
|
|
57
|
+
|
|
58
|
+
- The regression primitive exists: `fullstackgtm diff --before a.json
|
|
59
|
+
--after b.json --fail-on-new-findings` exits 2 when a (rule, record) pair
|
|
60
|
+
fires that didn't before. "New" is a stable finding id — the hash of
|
|
61
|
+
(ruleId, objectId).
|
|
62
|
+
- **The nightly watch recipe** ("CRM CI"): scheduled
|
|
63
|
+
`snapshot → audit → diff` against yesterday's snapshot, alert on exit 2.
|
|
64
|
+
- **Attribution** (planned, 0.13): capture HubSpot's read-only
|
|
65
|
+
`hs_object_source`, `hs_object_source_label`, `hs_object_source_id` into
|
|
66
|
+
the canonical model so duplicate findings can say *"all five created by
|
|
67
|
+
integration X"*. The fix for recurring dupes is upstream, in the writer.
|
|
68
|
+
- Incremental reads (`snapshot --since`) exist for all three connectors;
|
|
69
|
+
caveats: HubSpot deltas carry no associations and cap at 10k per object,
|
|
70
|
+
Stripe deltas catch creations only.
|
|
71
|
+
|
|
72
|
+
## U — Update: governed merge
|
|
73
|
+
|
|
74
|
+
**Platform facts:** HubSpot's v3 merge endpoint
|
|
75
|
+
(`POST /crm/v3/objects/{type}/merge`) supports contacts, companies,
|
|
76
|
+
**deals**, and tickets today (the 2019 "no deal merge API" changelog is
|
|
77
|
+
obsolete). Merges are pairwise, the loser is auto-archived, primary's
|
|
78
|
+
values win, **merges cannot be undone**, and a record stops merging after
|
|
79
|
+
250 cumulative merges. Salesforce merge is SOAP/Apex only (no REST), only
|
|
80
|
+
Lead/Contact/Account/Case, max 3 records per call.
|
|
81
|
+
|
|
82
|
+
**The gap:** our three duplicate rules (`duplicate-account-domain`,
|
|
83
|
+
`duplicate-contact-email`, `duplicate-open-deal`) detect groups but emit
|
|
84
|
+
only merge-review *tasks* — detection without remediation.
|
|
85
|
+
|
|
86
|
+
**The plan (0.12):** a `merge_records` operation type —
|
|
87
|
+
`requires_human_survivor_selection` placeholder, survivor heuristics in
|
|
88
|
+
`suggest` (ordered, evidence-based: most engagements → oldest → most
|
|
89
|
+
complete, each with a written reason), high risk, approval required, with
|
|
90
|
+
the irreversibility called out in the plan text. The dry-run plan is the
|
|
91
|
+
preview every commercial tool charges for; the pre-apply snapshot is the
|
|
92
|
+
loser-record archive. HubSpot first; Salesforce merge documented as
|
|
93
|
+
unsupported until an Apex path justifies itself.
|
|
94
|
+
|
|
95
|
+
## D — Delete/Archive: the exit ramp
|
|
96
|
+
|
|
97
|
+
- `archive_record` exists in both connectors (HubSpot DELETE = archive,
|
|
98
|
+
restorable ~90 days; Salesforce DELETE = recycle bin) but no built-in
|
|
99
|
+
rule emits it. It is the endpoint for reviewed orphans; merge losers are
|
|
100
|
+
archived by the merge APIs themselves.
|
|
101
|
+
- All destructive operations stay high-risk, approval-required, and behind
|
|
102
|
+
compare-and-set where the platform exposes a readable before-value.
|
|
103
|
+
|
|
104
|
+
## Write-path integrity rules (our own faucet)
|
|
105
|
+
|
|
106
|
+
Lessons from auditing our own apply path:
|
|
107
|
+
|
|
108
|
+
1. Field writes (`set_field`/`clear_field`/`link_record`) are protected by
|
|
109
|
+
compare-and-set: the live value is read back and a drifted value returns
|
|
110
|
+
`conflict` without writing.
|
|
111
|
+
2. CAS is only as good as `readField` — for HubSpot deals, `accountId` is
|
|
112
|
+
an association, not a property, and must be read via the associations
|
|
113
|
+
API or CAS silently passes on replay (fixed in 0.11.1).
|
|
114
|
+
3. `create:` values are resolve-first and deduped within a plan run
|
|
115
|
+
(0.11.1); `create_task` operations carry an idempotency token in the
|
|
116
|
+
task body and pre-check for it (0.11.1, fail-open on search errors).
|
|
117
|
+
4. Plan replays are blocked at the store (`--plan-id` of an applied plan
|
|
118
|
+
refuses); the `--plan <file>` path relies on CAS — prefer the store.
|
|
119
|
+
|
|
120
|
+
## The operating cadence
|
|
121
|
+
|
|
122
|
+
**Gate** creates (resolve-first, upserts, native rules on) →
|
|
123
|
+
**Watch** nightly (snapshot, diff, exit-2 alert) →
|
|
124
|
+
**Fix** governed (audit → suggest → approve → apply, incl. merge) →
|
|
125
|
+
**Verify** (readback, re-audit, drift report) →
|
|
126
|
+
**Attribute** (provenance names the writer; fix the faucet).
|
|
127
|
+
|
|
128
|
+
## Build order
|
|
129
|
+
|
|
130
|
+
| Release | Scope |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
|
|
133
|
+
| 0.12 (shipped) | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions capped at low confidence; the three duplicate rules emit governed merges instead of review tasks |
|
|
134
|
+
| 0.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
|
|
135
|
+
| docs | The nightly watch recipe (existing flags, documented as CRM CI) |
|
package/llms.txt
CHANGED
|
@@ -15,6 +15,7 @@ at/above `--fail-on`.
|
|
|
15
15
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
|
16
16
|
- [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
|
|
17
17
|
- [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
|
|
18
|
+
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
18
19
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
19
20
|
|
|
20
21
|
## Key invariants
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
|
@@ -54,6 +54,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
54
54
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
55
55
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
56
56
|
const mappings = options.fieldMappings;
|
|
57
|
+
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
58
|
+
// search API is eventually consistent, so a just-created company is
|
|
59
|
+
// invisible to search — this map is the authoritative same-run record.
|
|
60
|
+
const createdCompaniesByName = new Map<string, string>();
|
|
57
61
|
|
|
58
62
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
59
63
|
const token = await options.getAccessToken();
|
|
@@ -384,21 +388,46 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
384
388
|
if (!companyId) {
|
|
385
389
|
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
386
390
|
}
|
|
387
|
-
// `create:<Name>`
|
|
388
|
-
//
|
|
389
|
-
// the
|
|
391
|
+
// `create:<Name>` is resolve-first: link to an existing company when one
|
|
392
|
+
// unambiguously matches, refuse on ambiguity, create only on a confirmed
|
|
393
|
+
// miss — and never create the same name twice within one apply run
|
|
394
|
+
// (HubSpot's search API is eventually consistent, so a just-created
|
|
395
|
+
// record is invisible to search for several seconds).
|
|
390
396
|
let createdCompanyName: string | null = null;
|
|
397
|
+
let resolvedExisting = false;
|
|
391
398
|
if (companyId.startsWith("create:")) {
|
|
392
399
|
const name = companyId.slice("create:".length).trim();
|
|
393
400
|
if (!name) {
|
|
394
401
|
return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
|
|
395
402
|
}
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
403
|
+
const nameKey = name.toLowerCase();
|
|
404
|
+
const alreadyCreated = createdCompaniesByName.get(nameKey);
|
|
405
|
+
if (alreadyCreated) {
|
|
406
|
+
companyId = alreadyCreated;
|
|
407
|
+
resolvedExisting = true;
|
|
408
|
+
} else {
|
|
409
|
+
const matches = await searchCompaniesByName(name);
|
|
410
|
+
if (matches.length > 1) {
|
|
411
|
+
return {
|
|
412
|
+
operationId: operation.id,
|
|
413
|
+
status: "skipped",
|
|
414
|
+
detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (matches.length === 1) {
|
|
418
|
+
companyId = matches[0];
|
|
419
|
+
resolvedExisting = true;
|
|
420
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
421
|
+
} else {
|
|
422
|
+
const created = await request(`/crm/v3/objects/companies`, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
body: JSON.stringify({ properties: { name } }),
|
|
425
|
+
});
|
|
426
|
+
companyId = String(created.id);
|
|
427
|
+
createdCompanyName = name;
|
|
428
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
402
431
|
}
|
|
403
432
|
await request(
|
|
404
433
|
`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
@@ -409,11 +438,26 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
409
438
|
status: "applied",
|
|
410
439
|
detail: createdCompanyName
|
|
411
440
|
? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
|
|
412
|
-
:
|
|
441
|
+
: resolvedExisting
|
|
442
|
+
? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
|
|
443
|
+
: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
413
444
|
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
414
445
|
};
|
|
415
446
|
}
|
|
416
447
|
|
|
448
|
+
/** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
|
|
449
|
+
async function searchCompaniesByName(name: string): Promise<string[]> {
|
|
450
|
+
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
451
|
+
method: "POST",
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
|
|
454
|
+
properties: ["name"],
|
|
455
|
+
limit: 3,
|
|
456
|
+
}),
|
|
457
|
+
});
|
|
458
|
+
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
459
|
+
}
|
|
460
|
+
|
|
417
461
|
async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
418
462
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
419
463
|
if (associationTypeId === undefined) {
|
|
@@ -424,7 +468,33 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
424
468
|
};
|
|
425
469
|
}
|
|
426
470
|
const subject = operation.field ? humanizeField(operation.field) : "Follow up";
|
|
427
|
-
|
|
471
|
+
// The operation id doubles as an idempotency token: it is stamped into
|
|
472
|
+
// the task body and pre-checked so a replayed plan does not create the
|
|
473
|
+
// same task twice. Fail-open — a search hiccup must not block the apply.
|
|
474
|
+
const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
|
|
475
|
+
try {
|
|
476
|
+
const existing = await request(`/crm/v3/objects/tasks/search`, {
|
|
477
|
+
method: "POST",
|
|
478
|
+
body: JSON.stringify({
|
|
479
|
+
filterGroups: [
|
|
480
|
+
{ filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
|
|
481
|
+
],
|
|
482
|
+
limit: 1,
|
|
483
|
+
}),
|
|
484
|
+
});
|
|
485
|
+
const hit = (existing?.results ?? [])[0] as { id?: string } | undefined;
|
|
486
|
+
if (hit?.id) {
|
|
487
|
+
return {
|
|
488
|
+
operationId: operation.id,
|
|
489
|
+
status: "skipped",
|
|
490
|
+
detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
|
|
491
|
+
providerData: { id: hit.id, existing: true },
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// fall through to create
|
|
496
|
+
}
|
|
497
|
+
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
428
498
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
429
499
|
method: "POST",
|
|
430
500
|
body: JSON.stringify({
|
|
@@ -472,6 +542,75 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
472
542
|
};
|
|
473
543
|
}
|
|
474
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Merge a duplicate group into the approved survivor via HubSpot's v3
|
|
547
|
+
* merge API (supported for contacts, companies, deals, and tickets).
|
|
548
|
+
* Merges are pairwise and IRREVERSIBLE; the survivor's values win on
|
|
549
|
+
* conflict and each loser is archived by HubSpot. A loser that is already
|
|
550
|
+
* gone (404 — e.g. a replayed plan) is treated as already merged.
|
|
551
|
+
*/
|
|
552
|
+
async function mergeRecords(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
553
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
554
|
+
if (!objectPath || operation.objectType === "user" || operation.objectType === "activity") {
|
|
555
|
+
return {
|
|
556
|
+
operationId: operation.id,
|
|
557
|
+
status: "skipped",
|
|
558
|
+
detail: "merge_records is supported for accounts, contacts, and deals.",
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const survivorId = String(operation.afterValue ?? "");
|
|
562
|
+
const groupIds = Array.isArray(operation.beforeValue)
|
|
563
|
+
? operation.beforeValue.map((id) => String(id))
|
|
564
|
+
: [];
|
|
565
|
+
if (!survivorId || groupIds.length < 2) {
|
|
566
|
+
return {
|
|
567
|
+
operationId: operation.id,
|
|
568
|
+
status: "skipped",
|
|
569
|
+
detail: "merge_records needs a survivor id (afterValue) and the duplicate group ids (beforeValue).",
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
if (!groupIds.includes(survivorId)) {
|
|
573
|
+
return {
|
|
574
|
+
operationId: operation.id,
|
|
575
|
+
status: "skipped",
|
|
576
|
+
detail: `Survivor ${survivorId} is not in the duplicate group (${groupIds.join(", ")}); refusing to merge into an unrelated record.`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const losers = groupIds.filter((id) => id !== survivorId);
|
|
580
|
+
const mergedIds: string[] = [];
|
|
581
|
+
const alreadyGoneIds: string[] = [];
|
|
582
|
+
for (const loser of losers) {
|
|
583
|
+
try {
|
|
584
|
+
await request(`/crm/v3/objects/${objectPath}/merge`, {
|
|
585
|
+
method: "POST",
|
|
586
|
+
body: JSON.stringify({ primaryObjectId: survivorId, objectIdToMerge: loser }),
|
|
587
|
+
});
|
|
588
|
+
mergedIds.push(loser);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
591
|
+
if (message.includes(" 404")) {
|
|
592
|
+
alreadyGoneIds.push(loser); // replayed plan: loser already merged/archived
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
operationId: operation.id,
|
|
597
|
+
status: "failed",
|
|
598
|
+
detail: `Merged ${mergedIds.length} of ${losers.length} into ${survivorId}, then failed on ${loser}: ${message}`,
|
|
599
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
operationId: operation.id,
|
|
605
|
+
status: mergedIds.length === 0 && alreadyGoneIds.length === losers.length ? "skipped" : "applied",
|
|
606
|
+
detail:
|
|
607
|
+
mergedIds.length === 0 && alreadyGoneIds.length === losers.length
|
|
608
|
+
? `All ${losers.length} duplicates were already merged into ${survivorId}; nothing to do.`
|
|
609
|
+
: `Merged ${mergedIds.length} duplicate ${objectPath} into ${survivorId}${alreadyGoneIds.length ? ` (${alreadyGoneIds.length} already gone)` : ""}. Irreversible.`,
|
|
610
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
475
614
|
async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
476
615
|
try {
|
|
477
616
|
switch (operation.operation) {
|
|
@@ -482,6 +621,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
482
621
|
return await linkRecord(operation);
|
|
483
622
|
case "create_task":
|
|
484
623
|
return await createTask(operation);
|
|
624
|
+
case "merge_records":
|
|
625
|
+
return await mergeRecords(operation);
|
|
485
626
|
case "archive_record":
|
|
486
627
|
return await archiveRecord(operation);
|
|
487
628
|
default:
|
|
@@ -519,6 +660,15 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
519
660
|
if (!objectPath || !mappingType) {
|
|
520
661
|
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
521
662
|
}
|
|
663
|
+
// accountId is an association in HubSpot, not a property — without this
|
|
664
|
+
// branch the compare-and-set on link_record reads null and passes blind.
|
|
665
|
+
if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
|
|
666
|
+
const data = await request(
|
|
667
|
+
`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`,
|
|
668
|
+
);
|
|
669
|
+
const first = (data?.results ?? [])[0] as { toObjectId?: number | string } | undefined;
|
|
670
|
+
return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
|
|
671
|
+
}
|
|
522
672
|
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
523
673
|
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
524
674
|
const data = await request(
|
|
@@ -63,6 +63,8 @@ export function createSalesforceConnector(
|
|
|
63
63
|
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
64
64
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
65
65
|
const mappings = options.fieldMappings;
|
|
66
|
+
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
67
|
+
const createdAccountsByName = new Map<string, string>();
|
|
66
68
|
|
|
67
69
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
68
70
|
const connection = await options.getConnection();
|
|
@@ -318,11 +320,29 @@ export function createSalesforceConnector(
|
|
|
318
320
|
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
319
321
|
};
|
|
320
322
|
}
|
|
323
|
+
// Idempotency: the operation id is stamped into the Description and
|
|
324
|
+
// pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
|
|
325
|
+
const token = `fsgtm:${operation.id}`;
|
|
326
|
+
try {
|
|
327
|
+
const existing = await query(
|
|
328
|
+
`SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`,
|
|
329
|
+
);
|
|
330
|
+
if (existing.length > 0) {
|
|
331
|
+
return {
|
|
332
|
+
operationId: operation.id,
|
|
333
|
+
status: "skipped",
|
|
334
|
+
detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
|
|
335
|
+
providerData: { id: String(existing[0].Id), existing: true },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// fall through to create
|
|
340
|
+
}
|
|
321
341
|
const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
|
|
322
342
|
method: "POST",
|
|
323
343
|
body: JSON.stringify({
|
|
324
344
|
Subject: operation.field ? humanizeField(operation.field) : "Follow up",
|
|
325
|
-
Description: String(operation.afterValue ?? operation.reason ?? "")
|
|
345
|
+
Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
|
|
326
346
|
Status: "Not Started",
|
|
327
347
|
Priority: "Normal",
|
|
328
348
|
...reference,
|
|
@@ -364,27 +384,67 @@ export function createSalesforceConnector(
|
|
|
364
384
|
// link_record on a deal is just setting AccountId in Salesforce.
|
|
365
385
|
return await setField(operation);
|
|
366
386
|
case "link_record": {
|
|
367
|
-
// `create:<Name>`
|
|
368
|
-
//
|
|
387
|
+
// `create:<Name>` is resolve-first: link to an unambiguous existing
|
|
388
|
+
// Account, refuse on ambiguity, create only on a confirmed miss —
|
|
389
|
+
// and never create the same name twice within one apply run.
|
|
369
390
|
const value = String(operation.afterValue ?? "");
|
|
370
391
|
if (value.startsWith("create:")) {
|
|
371
392
|
const name = value.slice("create:".length).trim();
|
|
372
393
|
if (!name) {
|
|
373
394
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
374
395
|
}
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
396
|
+
const nameKey = name.toLowerCase();
|
|
397
|
+
let accountId = createdAccountsByName.get(nameKey);
|
|
398
|
+
let createdNew = false;
|
|
399
|
+
if (!accountId) {
|
|
400
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
401
|
+
const matches = await query(
|
|
402
|
+
`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`,
|
|
403
|
+
);
|
|
404
|
+
if (matches.length > 1) {
|
|
405
|
+
return {
|
|
406
|
+
operationId: operation.id,
|
|
407
|
+
status: "skipped",
|
|
408
|
+
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (matches.length === 1) {
|
|
412
|
+
accountId = String(matches[0].Id);
|
|
413
|
+
} else {
|
|
414
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
415
|
+
method: "POST",
|
|
416
|
+
body: JSON.stringify({ Name: name }),
|
|
417
|
+
});
|
|
418
|
+
accountId = String(created.id);
|
|
419
|
+
createdNew = true;
|
|
420
|
+
}
|
|
421
|
+
createdAccountsByName.set(nameKey, accountId);
|
|
422
|
+
}
|
|
423
|
+
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
380
424
|
return result.status === "applied"
|
|
381
|
-
? {
|
|
425
|
+
? {
|
|
426
|
+
...result,
|
|
427
|
+
detail: createdNew
|
|
428
|
+
? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
|
|
429
|
+
: `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
|
|
430
|
+
providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
|
|
431
|
+
}
|
|
382
432
|
: result;
|
|
383
433
|
}
|
|
384
434
|
return await setField({ ...operation, operation: "set_field" });
|
|
385
435
|
}
|
|
386
436
|
case "create_task":
|
|
387
437
|
return await createTask(operation);
|
|
438
|
+
case "merge_records":
|
|
439
|
+
// Salesforce merge exists only in the SOAP API and Apex (Lead,
|
|
440
|
+
// Contact, Account, Case; max 3 records) — there is no REST merge
|
|
441
|
+
// resource. Surface that honestly instead of half-merging.
|
|
442
|
+
return {
|
|
443
|
+
operationId: operation.id,
|
|
444
|
+
status: "skipped",
|
|
445
|
+
detail:
|
|
446
|
+
"Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
|
|
447
|
+
};
|
|
388
448
|
case "archive_record":
|
|
389
449
|
return await archiveRecord(operation);
|
|
390
450
|
default:
|
package/src/merge.ts
CHANGED
|
@@ -55,7 +55,7 @@ const CONFLICT_IGNORED_FIELDS = new Set([
|
|
|
55
55
|
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
56
56
|
]);
|
|
57
57
|
|
|
58
|
-
function normalizeDomain(domain?: string): string | undefined {
|
|
58
|
+
export function normalizeDomain(domain?: string): string | undefined {
|
|
59
59
|
if (!domain) return undefined;
|
|
60
60
|
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
|
|
61
61
|
}
|
package/src/rules.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.ts";
|
|
1
2
|
import type {
|
|
2
3
|
AuditFinding,
|
|
3
4
|
CanonicalActivity,
|
|
@@ -333,7 +334,7 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
|
|
|
333
334
|
evaluate: ({ snapshot }) => {
|
|
334
335
|
const findings: AuditFinding[] = [];
|
|
335
336
|
const operations = [];
|
|
336
|
-
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
|
|
337
|
+
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => normalizeDomain(account.domain))) {
|
|
337
338
|
const anchor = accounts[0];
|
|
338
339
|
findings.push({
|
|
339
340
|
id: auditFindingId("duplicate-account-domain", anchor.id),
|
|
@@ -349,13 +350,15 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
|
|
|
349
350
|
id: patchOperationId("duplicate-account-domain", anchor.id),
|
|
350
351
|
objectType: "account" as const,
|
|
351
352
|
objectId: anchor.id,
|
|
352
|
-
operation: "
|
|
353
|
-
field: "
|
|
354
|
-
beforeValue:
|
|
355
|
-
afterValue:
|
|
356
|
-
reason:
|
|
357
|
-
riskLevel: "
|
|
353
|
+
operation: "merge_records" as const,
|
|
354
|
+
field: "merge",
|
|
355
|
+
beforeValue: accounts.map((account) => account.id),
|
|
356
|
+
afterValue: "requires_human_survivor_selection",
|
|
357
|
+
reason: `Duplicate accounts split pipeline, attribution, and ownership. Merge the ${accounts.length} accounts sharing ${domain} into one survivor.`,
|
|
358
|
+
riskLevel: "high" as const,
|
|
358
359
|
approvalRequired: true,
|
|
360
|
+
rollback:
|
|
361
|
+
"IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
359
362
|
});
|
|
360
363
|
}
|
|
361
364
|
return { findings, operations };
|
|
@@ -387,13 +390,15 @@ export const duplicateContactEmailRule: GtmAuditRule = {
|
|
|
387
390
|
id: patchOperationId("duplicate-contact-email", anchor.id),
|
|
388
391
|
objectType: "contact" as const,
|
|
389
392
|
objectId: anchor.id,
|
|
390
|
-
operation: "
|
|
391
|
-
field: "
|
|
392
|
-
beforeValue:
|
|
393
|
-
afterValue:
|
|
394
|
-
reason:
|
|
395
|
-
riskLevel: "
|
|
393
|
+
operation: "merge_records" as const,
|
|
394
|
+
field: "merge",
|
|
395
|
+
beforeValue: contacts.map((contact) => contact.id),
|
|
396
|
+
afterValue: "requires_human_survivor_selection",
|
|
397
|
+
reason: `Duplicate contacts fragment engagement history and double-route outreach. Merge the ${contacts.length} contacts sharing ${email} into one survivor.`,
|
|
398
|
+
riskLevel: "high" as const,
|
|
396
399
|
approvalRequired: true,
|
|
400
|
+
rollback:
|
|
401
|
+
"IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
397
402
|
});
|
|
398
403
|
}
|
|
399
404
|
return { findings, operations };
|
|
@@ -436,13 +441,15 @@ export const duplicateOpenDealRule: GtmAuditRule = {
|
|
|
436
441
|
id: patchOperationId("duplicate-open-deal", anchor.id),
|
|
437
442
|
objectType: "deal" as const,
|
|
438
443
|
objectId: anchor.id,
|
|
439
|
-
operation: "
|
|
440
|
-
field: "
|
|
441
|
-
beforeValue:
|
|
442
|
-
afterValue:
|
|
443
|
-
reason:
|
|
444
|
-
riskLevel: "
|
|
444
|
+
operation: "merge_records" as const,
|
|
445
|
+
field: "merge",
|
|
446
|
+
beforeValue: deals.map((deal) => deal.id),
|
|
447
|
+
afterValue: "requires_human_survivor_selection",
|
|
448
|
+
reason: `Duplicate open deals inflate pipeline and forecast the same revenue more than once. Merge the ${deals.length} deals named "${anchor.name}" into one survivor.`,
|
|
449
|
+
riskLevel: "high" as const,
|
|
445
450
|
approvalRequired: true,
|
|
451
|
+
rollback:
|
|
452
|
+
"IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
446
453
|
});
|
|
447
454
|
}
|
|
448
455
|
return { findings, operations };
|