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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,65 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.12.0] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
Governed merges: the Remediate layer of the
|
|
11
|
+
[CRM-health lifecycle](./docs/crm-health-lifecycle.md). Duplicate detection
|
|
12
|
+
now ends in an approvable merge, not a chore.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`merge_records` operation type**: merges a duplicate group
|
|
17
|
+
(`beforeValue` = group ids) into an approved survivor (`afterValue`).
|
|
18
|
+
HubSpot connector implements it via the v3 merge API for companies,
|
|
19
|
+
contacts, and deals — pairwise, survivor's values win, losers archived by
|
|
20
|
+
HubSpot, **irreversible** (called out in every operation's rollback text).
|
|
21
|
+
Refuses survivors outside the group; treats 404 losers as already merged,
|
|
22
|
+
so replayed plans are safe. Salesforce skips honestly (merge is SOAP/Apex
|
|
23
|
+
only on that platform).
|
|
24
|
+
- **Survivor suggestions in `suggest`**: deterministic ranking — most
|
|
25
|
+
complete record, then most recent activity — with the evidence written
|
|
26
|
+
into the reason. **Capped at low confidence by design**: an irreversible
|
|
27
|
+
merge can never clear the default bulk-approval bar; accepting one takes
|
|
28
|
+
`--min-confidence low` or an explicit `--value`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **The three duplicate rules now emit `merge_records`** (high risk,
|
|
33
|
+
approval required, irreversibility in the rollback text) instead of
|
|
34
|
+
`create_task` review chores — detection now connects to remediation
|
|
35
|
+
inside the governed loop.
|
|
36
|
+
|
|
37
|
+
## [0.11.1] — 2026-06-11
|
|
38
|
+
|
|
39
|
+
Write-path integrity: fixes our own dupe faucets, found auditing the apply
|
|
40
|
+
path for the new [CRM-health lifecycle](./docs/crm-health-lifecycle.md) doc.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **docs/crm-health-lifecycle.md**: the full CRUD lifecycle for keeping a
|
|
45
|
+
CRM healthy — Prevent → Detect → Remediate → Verify/Attribute — grounded
|
|
46
|
+
in verified platform behavior (HubSpot/Salesforce dedupe and merge
|
|
47
|
+
support) and the build order toward governed merges and a resolve gate.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **`create:<Name>` is now resolve-first**: it links to an unambiguous
|
|
52
|
+
existing company/account instead of creating, refuses on ambiguity, and
|
|
53
|
+
creates only on a confirmed miss — and never creates the same name twice
|
|
54
|
+
within one apply run (HubSpot search is eventually consistent, so the
|
|
55
|
+
same-run record is authoritative).
|
|
56
|
+
- **HubSpot compare-and-set on `link_record` is no longer blind**:
|
|
57
|
+
`readField("deal"|"contact", id, "accountId")` reads the actual company
|
|
58
|
+
association (it is not a property), so replaying an applied link returns
|
|
59
|
+
`conflict` instead of silently re-creating companies.
|
|
60
|
+
- **`create_task` is idempotent**: the operation id is stamped into the
|
|
61
|
+
task body as a token and pre-checked (fail-open), so replayed plans no
|
|
62
|
+
longer duplicate merge-review tasks on either provider.
|
|
63
|
+
- **`duplicate-account-domain` normalizes domains** the same way merge
|
|
64
|
+
does — `www.acme.com`, `https://acme.com/about`, and `acme.com` now
|
|
65
|
+
group as duplicates.
|
|
66
|
+
|
|
8
67
|
## [0.11.0] — 2026-06-11
|
|
9
68
|
|
|
10
69
|
Canonicalizes the paths discovered dogfooding against a real portal: the
|
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ fullstackgtm plans approve patch_plan_abc123 --values-from suggestions.json #
|
|
|
59
59
|
fullstackgtm apply --plan-id patch_plan_abc123 --provider hubspot
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Widen the bar deliberately: `--min-confidence low` accepts single-signal matches; `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
|
|
62
|
+
Widen the bar deliberately: `--min-confidence low` accepts single-signal matches and **merge survivor suggestions** (irreversible merges are capped at low confidence by design, so the default bar never bulk-approves one); `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
65
|
# 3. Hand the findings to whoever owns the CRM: a client-ready report
|
|
@@ -22,6 +22,10 @@ export function createHubspotConnector(options) {
|
|
|
22
22
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
23
23
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
24
24
|
const mappings = options.fieldMappings;
|
|
25
|
+
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
26
|
+
// search API is eventually consistent, so a just-created company is
|
|
27
|
+
// invisible to search — this map is the authoritative same-run record.
|
|
28
|
+
const createdCompaniesByName = new Map();
|
|
25
29
|
async function request(path, init = {}) {
|
|
26
30
|
const token = await options.getAccessToken();
|
|
27
31
|
let response;
|
|
@@ -286,21 +290,48 @@ export function createHubspotConnector(options) {
|
|
|
286
290
|
if (!companyId) {
|
|
287
291
|
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
288
292
|
}
|
|
289
|
-
// `create:<Name>`
|
|
290
|
-
//
|
|
291
|
-
// the
|
|
293
|
+
// `create:<Name>` is resolve-first: link to an existing company when one
|
|
294
|
+
// unambiguously matches, refuse on ambiguity, create only on a confirmed
|
|
295
|
+
// miss — and never create the same name twice within one apply run
|
|
296
|
+
// (HubSpot's search API is eventually consistent, so a just-created
|
|
297
|
+
// record is invisible to search for several seconds).
|
|
292
298
|
let createdCompanyName = null;
|
|
299
|
+
let resolvedExisting = false;
|
|
293
300
|
if (companyId.startsWith("create:")) {
|
|
294
301
|
const name = companyId.slice("create:".length).trim();
|
|
295
302
|
if (!name) {
|
|
296
303
|
return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
|
|
297
304
|
}
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
305
|
+
const nameKey = name.toLowerCase();
|
|
306
|
+
const alreadyCreated = createdCompaniesByName.get(nameKey);
|
|
307
|
+
if (alreadyCreated) {
|
|
308
|
+
companyId = alreadyCreated;
|
|
309
|
+
resolvedExisting = true;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const matches = await searchCompaniesByName(name);
|
|
313
|
+
if (matches.length > 1) {
|
|
314
|
+
return {
|
|
315
|
+
operationId: operation.id,
|
|
316
|
+
status: "skipped",
|
|
317
|
+
detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (matches.length === 1) {
|
|
321
|
+
companyId = matches[0];
|
|
322
|
+
resolvedExisting = true;
|
|
323
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const created = await request(`/crm/v3/objects/companies`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: JSON.stringify({ properties: { name } }),
|
|
329
|
+
});
|
|
330
|
+
companyId = String(created.id);
|
|
331
|
+
createdCompanyName = name;
|
|
332
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
304
335
|
}
|
|
305
336
|
await request(`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
306
337
|
return {
|
|
@@ -308,10 +339,24 @@ export function createHubspotConnector(options) {
|
|
|
308
339
|
status: "applied",
|
|
309
340
|
detail: createdCompanyName
|
|
310
341
|
? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
|
|
311
|
-
:
|
|
342
|
+
: resolvedExisting
|
|
343
|
+
? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
|
|
344
|
+
: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
312
345
|
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
313
346
|
};
|
|
314
347
|
}
|
|
348
|
+
/** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
|
|
349
|
+
async function searchCompaniesByName(name) {
|
|
350
|
+
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
351
|
+
method: "POST",
|
|
352
|
+
body: JSON.stringify({
|
|
353
|
+
filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
|
|
354
|
+
properties: ["name"],
|
|
355
|
+
limit: 3,
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
return (data?.results ?? []).map((row) => String(row.id));
|
|
359
|
+
}
|
|
315
360
|
async function createTask(operation) {
|
|
316
361
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
317
362
|
if (associationTypeId === undefined) {
|
|
@@ -322,7 +367,34 @@ export function createHubspotConnector(options) {
|
|
|
322
367
|
};
|
|
323
368
|
}
|
|
324
369
|
const subject = operation.field ? humanizeField(operation.field) : "Follow up";
|
|
325
|
-
|
|
370
|
+
// The operation id doubles as an idempotency token: it is stamped into
|
|
371
|
+
// the task body and pre-checked so a replayed plan does not create the
|
|
372
|
+
// same task twice. Fail-open — a search hiccup must not block the apply.
|
|
373
|
+
const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
|
|
374
|
+
try {
|
|
375
|
+
const existing = await request(`/crm/v3/objects/tasks/search`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: JSON.stringify({
|
|
378
|
+
filterGroups: [
|
|
379
|
+
{ filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
|
|
380
|
+
],
|
|
381
|
+
limit: 1,
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
const hit = (existing?.results ?? [])[0];
|
|
385
|
+
if (hit?.id) {
|
|
386
|
+
return {
|
|
387
|
+
operationId: operation.id,
|
|
388
|
+
status: "skipped",
|
|
389
|
+
detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
|
|
390
|
+
providerData: { id: hit.id, existing: true },
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// fall through to create
|
|
396
|
+
}
|
|
397
|
+
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
326
398
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
327
399
|
method: "POST",
|
|
328
400
|
body: JSON.stringify({
|
|
@@ -368,6 +440,74 @@ export function createHubspotConnector(options) {
|
|
|
368
440
|
detail: `Archived ${objectPath}/${operation.objectId}.`,
|
|
369
441
|
};
|
|
370
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Merge a duplicate group into the approved survivor via HubSpot's v3
|
|
445
|
+
* merge API (supported for contacts, companies, deals, and tickets).
|
|
446
|
+
* Merges are pairwise and IRREVERSIBLE; the survivor's values win on
|
|
447
|
+
* conflict and each loser is archived by HubSpot. A loser that is already
|
|
448
|
+
* gone (404 — e.g. a replayed plan) is treated as already merged.
|
|
449
|
+
*/
|
|
450
|
+
async function mergeRecords(operation) {
|
|
451
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
452
|
+
if (!objectPath || operation.objectType === "user" || operation.objectType === "activity") {
|
|
453
|
+
return {
|
|
454
|
+
operationId: operation.id,
|
|
455
|
+
status: "skipped",
|
|
456
|
+
detail: "merge_records is supported for accounts, contacts, and deals.",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const survivorId = String(operation.afterValue ?? "");
|
|
460
|
+
const groupIds = Array.isArray(operation.beforeValue)
|
|
461
|
+
? operation.beforeValue.map((id) => String(id))
|
|
462
|
+
: [];
|
|
463
|
+
if (!survivorId || groupIds.length < 2) {
|
|
464
|
+
return {
|
|
465
|
+
operationId: operation.id,
|
|
466
|
+
status: "skipped",
|
|
467
|
+
detail: "merge_records needs a survivor id (afterValue) and the duplicate group ids (beforeValue).",
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (!groupIds.includes(survivorId)) {
|
|
471
|
+
return {
|
|
472
|
+
operationId: operation.id,
|
|
473
|
+
status: "skipped",
|
|
474
|
+
detail: `Survivor ${survivorId} is not in the duplicate group (${groupIds.join(", ")}); refusing to merge into an unrelated record.`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const losers = groupIds.filter((id) => id !== survivorId);
|
|
478
|
+
const mergedIds = [];
|
|
479
|
+
const alreadyGoneIds = [];
|
|
480
|
+
for (const loser of losers) {
|
|
481
|
+
try {
|
|
482
|
+
await request(`/crm/v3/objects/${objectPath}/merge`, {
|
|
483
|
+
method: "POST",
|
|
484
|
+
body: JSON.stringify({ primaryObjectId: survivorId, objectIdToMerge: loser }),
|
|
485
|
+
});
|
|
486
|
+
mergedIds.push(loser);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
490
|
+
if (message.includes(" 404")) {
|
|
491
|
+
alreadyGoneIds.push(loser); // replayed plan: loser already merged/archived
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
operationId: operation.id,
|
|
496
|
+
status: "failed",
|
|
497
|
+
detail: `Merged ${mergedIds.length} of ${losers.length} into ${survivorId}, then failed on ${loser}: ${message}`,
|
|
498
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
operationId: operation.id,
|
|
504
|
+
status: mergedIds.length === 0 && alreadyGoneIds.length === losers.length ? "skipped" : "applied",
|
|
505
|
+
detail: mergedIds.length === 0 && alreadyGoneIds.length === losers.length
|
|
506
|
+
? `All ${losers.length} duplicates were already merged into ${survivorId}; nothing to do.`
|
|
507
|
+
: `Merged ${mergedIds.length} duplicate ${objectPath} into ${survivorId}${alreadyGoneIds.length ? ` (${alreadyGoneIds.length} already gone)` : ""}. Irreversible.`,
|
|
508
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
371
511
|
async function applyOperation(operation) {
|
|
372
512
|
try {
|
|
373
513
|
switch (operation.operation) {
|
|
@@ -378,6 +518,8 @@ export function createHubspotConnector(options) {
|
|
|
378
518
|
return await linkRecord(operation);
|
|
379
519
|
case "create_task":
|
|
380
520
|
return await createTask(operation);
|
|
521
|
+
case "merge_records":
|
|
522
|
+
return await mergeRecords(operation);
|
|
381
523
|
case "archive_record":
|
|
382
524
|
return await archiveRecord(operation);
|
|
383
525
|
default:
|
|
@@ -405,6 +547,13 @@ export function createHubspotConnector(options) {
|
|
|
405
547
|
if (!objectPath || !mappingType) {
|
|
406
548
|
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
407
549
|
}
|
|
550
|
+
// accountId is an association in HubSpot, not a property — without this
|
|
551
|
+
// branch the compare-and-set on link_record reads null and passes blind.
|
|
552
|
+
if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
|
|
553
|
+
const data = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`);
|
|
554
|
+
const first = (data?.results ?? [])[0];
|
|
555
|
+
return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
|
|
556
|
+
}
|
|
408
557
|
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
409
558
|
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
410
559
|
const data = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`);
|
|
@@ -23,6 +23,8 @@ export function createSalesforceConnector(options) {
|
|
|
23
23
|
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
24
24
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
25
25
|
const mappings = options.fieldMappings;
|
|
26
|
+
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
27
|
+
const createdAccountsByName = new Map();
|
|
26
28
|
async function request(path, init = {}) {
|
|
27
29
|
const connection = await options.getConnection();
|
|
28
30
|
const url = path.startsWith("http")
|
|
@@ -228,11 +230,28 @@ export function createSalesforceConnector(options) {
|
|
|
228
230
|
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
229
231
|
};
|
|
230
232
|
}
|
|
233
|
+
// Idempotency: the operation id is stamped into the Description and
|
|
234
|
+
// pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
|
|
235
|
+
const token = `fsgtm:${operation.id}`;
|
|
236
|
+
try {
|
|
237
|
+
const existing = await query(`SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`);
|
|
238
|
+
if (existing.length > 0) {
|
|
239
|
+
return {
|
|
240
|
+
operationId: operation.id,
|
|
241
|
+
status: "skipped",
|
|
242
|
+
detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
|
|
243
|
+
providerData: { id: String(existing[0].Id), existing: true },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// fall through to create
|
|
249
|
+
}
|
|
231
250
|
const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
|
|
232
251
|
method: "POST",
|
|
233
252
|
body: JSON.stringify({
|
|
234
253
|
Subject: operation.field ? humanizeField(operation.field) : "Follow up",
|
|
235
|
-
Description: String(operation.afterValue ?? operation.reason ?? "")
|
|
254
|
+
Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
|
|
236
255
|
Status: "Not Started",
|
|
237
256
|
Priority: "Normal",
|
|
238
257
|
...reference,
|
|
@@ -269,27 +288,65 @@ export function createSalesforceConnector(options) {
|
|
|
269
288
|
// link_record on a deal is just setting AccountId in Salesforce.
|
|
270
289
|
return await setField(operation);
|
|
271
290
|
case "link_record": {
|
|
272
|
-
// `create:<Name>`
|
|
273
|
-
//
|
|
291
|
+
// `create:<Name>` is resolve-first: link to an unambiguous existing
|
|
292
|
+
// Account, refuse on ambiguity, create only on a confirmed miss —
|
|
293
|
+
// and never create the same name twice within one apply run.
|
|
274
294
|
const value = String(operation.afterValue ?? "");
|
|
275
295
|
if (value.startsWith("create:")) {
|
|
276
296
|
const name = value.slice("create:".length).trim();
|
|
277
297
|
if (!name) {
|
|
278
298
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
279
299
|
}
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
300
|
+
const nameKey = name.toLowerCase();
|
|
301
|
+
let accountId = createdAccountsByName.get(nameKey);
|
|
302
|
+
let createdNew = false;
|
|
303
|
+
if (!accountId) {
|
|
304
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
305
|
+
const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
|
|
306
|
+
if (matches.length > 1) {
|
|
307
|
+
return {
|
|
308
|
+
operationId: operation.id,
|
|
309
|
+
status: "skipped",
|
|
310
|
+
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (matches.length === 1) {
|
|
314
|
+
accountId = String(matches[0].Id);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
body: JSON.stringify({ Name: name }),
|
|
320
|
+
});
|
|
321
|
+
accountId = String(created.id);
|
|
322
|
+
createdNew = true;
|
|
323
|
+
}
|
|
324
|
+
createdAccountsByName.set(nameKey, accountId);
|
|
325
|
+
}
|
|
326
|
+
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
285
327
|
return result.status === "applied"
|
|
286
|
-
? {
|
|
328
|
+
? {
|
|
329
|
+
...result,
|
|
330
|
+
detail: createdNew
|
|
331
|
+
? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
|
|
332
|
+
: `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
|
|
333
|
+
providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
|
|
334
|
+
}
|
|
287
335
|
: result;
|
|
288
336
|
}
|
|
289
337
|
return await setField({ ...operation, operation: "set_field" });
|
|
290
338
|
}
|
|
291
339
|
case "create_task":
|
|
292
340
|
return await createTask(operation);
|
|
341
|
+
case "merge_records":
|
|
342
|
+
// Salesforce merge exists only in the SOAP API and Apex (Lead,
|
|
343
|
+
// Contact, Account, Case; max 3 records) — there is no REST merge
|
|
344
|
+
// resource. Surface that honestly instead of half-merging.
|
|
345
|
+
return {
|
|
346
|
+
operationId: operation.id,
|
|
347
|
+
status: "skipped",
|
|
348
|
+
detail: "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.",
|
|
349
|
+
};
|
|
293
350
|
case "archive_record":
|
|
294
351
|
return await archiveRecord(operation);
|
|
295
352
|
default:
|
package/dist/merge.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type MergeReport = {
|
|
|
42
42
|
conflicts: MergeConflict[];
|
|
43
43
|
suggestions: MergeSuggestion[];
|
|
44
44
|
};
|
|
45
|
+
export declare function normalizeDomain(domain?: string): string | undefined;
|
|
45
46
|
export declare function mergeSnapshots(snapshots: CanonicalGtmSnapshot[]): {
|
|
46
47
|
snapshot: CanonicalGtmSnapshot;
|
|
47
48
|
report: MergeReport;
|
package/dist/merge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const CONFLICT_IGNORED_FIELDS = new Set([
|
|
2
2
|
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
3
3
|
]);
|
|
4
|
-
function normalizeDomain(domain) {
|
|
4
|
+
export function normalizeDomain(domain) {
|
|
5
5
|
if (!domain)
|
|
6
6
|
return undefined;
|
|
7
7
|
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
|
package/dist/rules.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.js";
|
|
1
2
|
/**
|
|
2
3
|
* Placeholder used as `afterValue` when the right value is a human decision
|
|
3
4
|
* (e.g. which owner to assign). Apply orchestration refuses to write these
|
|
@@ -316,7 +317,7 @@ export const duplicateAccountDomainRule = {
|
|
|
316
317
|
evaluate: ({ snapshot }) => {
|
|
317
318
|
const findings = [];
|
|
318
319
|
const operations = [];
|
|
319
|
-
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
|
|
320
|
+
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => normalizeDomain(account.domain))) {
|
|
320
321
|
const anchor = accounts[0];
|
|
321
322
|
findings.push({
|
|
322
323
|
id: auditFindingId("duplicate-account-domain", anchor.id),
|
|
@@ -332,13 +333,14 @@ export const duplicateAccountDomainRule = {
|
|
|
332
333
|
id: patchOperationId("duplicate-account-domain", anchor.id),
|
|
333
334
|
objectType: "account",
|
|
334
335
|
objectId: anchor.id,
|
|
335
|
-
operation: "
|
|
336
|
-
field: "
|
|
337
|
-
beforeValue:
|
|
338
|
-
afterValue:
|
|
339
|
-
reason:
|
|
340
|
-
riskLevel: "
|
|
336
|
+
operation: "merge_records",
|
|
337
|
+
field: "merge",
|
|
338
|
+
beforeValue: accounts.map((account) => account.id),
|
|
339
|
+
afterValue: "requires_human_survivor_selection",
|
|
340
|
+
reason: `Duplicate accounts split pipeline, attribution, and ownership. Merge the ${accounts.length} accounts sharing ${domain} into one survivor.`,
|
|
341
|
+
riskLevel: "high",
|
|
341
342
|
approvalRequired: true,
|
|
343
|
+
rollback: "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.",
|
|
342
344
|
});
|
|
343
345
|
}
|
|
344
346
|
return { findings, operations };
|
|
@@ -368,13 +370,14 @@ export const duplicateContactEmailRule = {
|
|
|
368
370
|
id: patchOperationId("duplicate-contact-email", anchor.id),
|
|
369
371
|
objectType: "contact",
|
|
370
372
|
objectId: anchor.id,
|
|
371
|
-
operation: "
|
|
372
|
-
field: "
|
|
373
|
-
beforeValue:
|
|
374
|
-
afterValue:
|
|
375
|
-
reason:
|
|
376
|
-
riskLevel: "
|
|
373
|
+
operation: "merge_records",
|
|
374
|
+
field: "merge",
|
|
375
|
+
beforeValue: contacts.map((contact) => contact.id),
|
|
376
|
+
afterValue: "requires_human_survivor_selection",
|
|
377
|
+
reason: `Duplicate contacts fragment engagement history and double-route outreach. Merge the ${contacts.length} contacts sharing ${email} into one survivor.`,
|
|
378
|
+
riskLevel: "high",
|
|
377
379
|
approvalRequired: true,
|
|
380
|
+
rollback: "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.",
|
|
378
381
|
});
|
|
379
382
|
}
|
|
380
383
|
return { findings, operations };
|
|
@@ -414,13 +417,14 @@ export const duplicateOpenDealRule = {
|
|
|
414
417
|
id: patchOperationId("duplicate-open-deal", anchor.id),
|
|
415
418
|
objectType: "deal",
|
|
416
419
|
objectId: anchor.id,
|
|
417
|
-
operation: "
|
|
418
|
-
field: "
|
|
419
|
-
beforeValue:
|
|
420
|
-
afterValue:
|
|
421
|
-
reason:
|
|
422
|
-
riskLevel: "
|
|
420
|
+
operation: "merge_records",
|
|
421
|
+
field: "merge",
|
|
422
|
+
beforeValue: deals.map((deal) => deal.id),
|
|
423
|
+
afterValue: "requires_human_survivor_selection",
|
|
424
|
+
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.`,
|
|
425
|
+
riskLevel: "high",
|
|
423
426
|
approvalRequired: true,
|
|
427
|
+
rollback: "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.",
|
|
424
428
|
});
|
|
425
429
|
}
|
|
426
430
|
return { findings, operations };
|
package/dist/suggest.js
CHANGED
|
@@ -22,6 +22,10 @@ export function suggestValues(plan, snapshot) {
|
|
|
22
22
|
suggestions.push(suggestDealAccount(operation, dealsById, accountsByNorm, accountsById, contactsByName));
|
|
23
23
|
continue;
|
|
24
24
|
}
|
|
25
|
+
if (placeholder === "requires_human_survivor_selection") {
|
|
26
|
+
suggestions.push(suggestSurvivor(operation, snapshot, dealsById));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
25
29
|
if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
|
|
26
30
|
suggestions.push({
|
|
27
31
|
operationId: operation.id,
|
|
@@ -140,6 +144,79 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
140
144
|
: `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
|
|
141
145
|
};
|
|
142
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Survivor selection for merge_records. Ranking is deterministic and
|
|
149
|
+
* evidence-based: most complete record first (count of populated canonical
|
|
150
|
+
* fields), most recent activity as the tiebreaker, group order last.
|
|
151
|
+
* Confidence is capped at "low" by design: merges are irreversible, so a
|
|
152
|
+
* survivor suggestion must never clear the default bulk-approval bar —
|
|
153
|
+
* accepting one requires --min-confidence low or an explicit --value.
|
|
154
|
+
*/
|
|
155
|
+
function suggestSurvivor(operation, snapshot, dealsById) {
|
|
156
|
+
const base = {
|
|
157
|
+
operationId: operation.id,
|
|
158
|
+
objectType: operation.objectType,
|
|
159
|
+
objectId: operation.objectId,
|
|
160
|
+
objectName: dealsById.get(operation.objectId)?.name,
|
|
161
|
+
placeholder: "requires_human_survivor_selection",
|
|
162
|
+
};
|
|
163
|
+
const groupIds = Array.isArray(operation.beforeValue)
|
|
164
|
+
? operation.beforeValue.map((id) => String(id))
|
|
165
|
+
: [];
|
|
166
|
+
if (groupIds.length < 2) {
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
suggestedValue: null,
|
|
170
|
+
confidence: "none",
|
|
171
|
+
reason: "Operation does not carry a duplicate group (expected ids in beforeValue).",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const collection = operation.objectType === "account"
|
|
175
|
+
? snapshot.accounts
|
|
176
|
+
: operation.objectType === "contact"
|
|
177
|
+
? snapshot.contacts
|
|
178
|
+
: operation.objectType === "deal"
|
|
179
|
+
? snapshot.deals
|
|
180
|
+
: [];
|
|
181
|
+
const records = groupIds
|
|
182
|
+
.map((id) => collection.find((row) => row.id === id))
|
|
183
|
+
.filter((row) => row !== undefined);
|
|
184
|
+
if (records.length !== groupIds.length) {
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
suggestedValue: null,
|
|
188
|
+
confidence: "none",
|
|
189
|
+
reason: "Not every group member is present in the snapshot; re-run the audit before merging.",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const ranked = [...records].sort((a, b) => {
|
|
193
|
+
const completeness = populatedFields(b) - populatedFields(a);
|
|
194
|
+
if (completeness !== 0)
|
|
195
|
+
return completeness;
|
|
196
|
+
return activityMs(b) - activityMs(a);
|
|
197
|
+
});
|
|
198
|
+
const winner = ranked[0];
|
|
199
|
+
const runnerUp = ranked[1];
|
|
200
|
+
const name = "name" in winner && typeof winner.name === "string" ? winner.name : winner.id;
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
suggestedValue: winner.id,
|
|
204
|
+
confidence: "low",
|
|
205
|
+
reason: `"${name}" (${winner.id}) is the most complete record in the group ` +
|
|
206
|
+
`(${populatedFields(winner)} populated fields vs ${populatedFields(runnerUp)}` +
|
|
207
|
+
`${activityMs(winner) > activityMs(runnerUp) ? ", and more recent activity" : ""}). ` +
|
|
208
|
+
`Merging is IRREVERSIBLE — survivor suggestions never exceed low confidence; ` +
|
|
209
|
+
`approve with --min-confidence low or an explicit --value after review.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function populatedFields(record) {
|
|
213
|
+
return Object.values(record).filter((value) => value !== undefined && value !== null && value !== "").length;
|
|
214
|
+
}
|
|
215
|
+
function activityMs(record) {
|
|
216
|
+
const value = record.lastActivityAt;
|
|
217
|
+
const parsed = value ? Date.parse(value) : NaN;
|
|
218
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
219
|
+
}
|
|
143
220
|
function normalize(value) {
|
|
144
221
|
return value
|
|
145
222
|
.toLowerCase()
|
package/dist/types.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type RiskLevel = "low" | "medium" | "high";
|
|
|
10
10
|
export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
|
|
11
11
|
export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
|
|
12
12
|
export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "unknown";
|
|
13
|
-
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task";
|
|
13
|
+
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "merge_records";
|
|
14
14
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
15
15
|
/**
|
|
16
16
|
* One claim that a canonical record exists in an external system. A record
|
package/docs/api.md
CHANGED
|
@@ -40,7 +40,7 @@ release.
|
|
|
40
40
|
- `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, readField?, fetchChanges? }`.
|
|
41
41
|
- Connectors never silently drop unresolvable records; audits surface them.
|
|
42
42
|
- `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
|
|
43
|
-
- `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record
|
|
43
|
+
- `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` honestly (SOAP/Apex-only on that platform).
|
|
44
44
|
- `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1; `applyOperation` implements every operation type.
|
|
45
45
|
- `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
|
|
46
46
|
|