fullstackgtm 0.21.2 → 0.22.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 +23 -0
- package/dist/bulkUpdate.d.ts +16 -1
- package/dist/bulkUpdate.js +88 -5
- package/dist/cli.js +214 -4
- package/dist/dedupe.d.ts +14 -0
- package/dist/dedupe.js +140 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/marketReport.js +95 -42
- package/dist/reassign.d.ts +19 -0
- package/dist/reassign.js +87 -0
- package/dist/suggest.js +67 -5
- package/package.json +1 -1
- package/src/bulkUpdate.ts +109 -6
- package/src/cli.ts +231 -4
- package/src/dedupe.ts +182 -0
- package/src/index.ts +3 -1
- package/src/marketReport.ts +110 -56
- package/src/reassign.ts +117 -0
- package/src/suggest.ts +69 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { auditSnapshot, defaultPolicy } from "./audit.js";
|
|
2
|
-
export { buildBulkUpdatePlan, parseWhere } from "./bulkUpdate.js";
|
|
2
|
+
export { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
|
|
3
|
+
export { buildDedupePlan, dedupeKey } from "./dedupe.js";
|
|
4
|
+
export { buildReassignPlans } from "./reassign.js";
|
|
3
5
|
export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
|
|
4
6
|
export { applyPatchPlan } from "./connector.js";
|
|
5
7
|
export { createHubspotConnector } from "./connectors/hubspot.js";
|
package/dist/marketReport.js
CHANGED
|
@@ -382,8 +382,9 @@ export function marketMapToHtml(config, set) {
|
|
|
382
382
|
const anchor = config.anchorVendor;
|
|
383
383
|
const e = escapeHtml;
|
|
384
384
|
const axisHtml = axisSectionsHtml(config, set);
|
|
385
|
-
const
|
|
386
|
-
|
|
385
|
+
const vendorNamesById = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
386
|
+
const frontByClaim = new Map(model.fronts.map((front) => [front.claimId, front]));
|
|
387
|
+
const matrixRow = (claimId) => {
|
|
387
388
|
const claim = claimsById.get(claimId);
|
|
388
389
|
if (!claim)
|
|
389
390
|
return "";
|
|
@@ -398,27 +399,87 @@ export function marketMapToHtml(config, set) {
|
|
|
398
399
|
return (`<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
|
|
399
400
|
`<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
|
|
400
401
|
`<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
};
|
|
403
|
+
// Claims grouped by front state, each group a collapsed <details> whose
|
|
404
|
+
// summary carries the stats a skimmer needs; the full matrix is one click
|
|
405
|
+
// away, not a wall the reader must climb to reach the takeaway.
|
|
406
|
+
const GROUPS = [
|
|
407
|
+
{ states: ["open", "vacant"], title: "Open ground", blurb: "no vendor is loud here" },
|
|
408
|
+
{ states: ["contested"], title: "Contested fronts", blurb: "2–3 vendors loud" },
|
|
409
|
+
{ states: ["owned"], title: "Owned fronts", blurb: "exactly one vendor loud" },
|
|
410
|
+
{ states: ["saturated"], title: "Saturated fronts", blurb: "4+ vendors loud" },
|
|
411
|
+
];
|
|
412
|
+
const groupedMatrix = GROUPS.map((group) => {
|
|
413
|
+
const claimIds = model.orderedClaimIds.filter((claimId) => group.states.includes(stateByClaim.get(claimId) ?? "vacant"));
|
|
414
|
+
if (claimIds.length === 0)
|
|
415
|
+
return "";
|
|
416
|
+
const anchorLoud = anchor
|
|
417
|
+
? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
|
|
418
|
+
: 0;
|
|
419
|
+
const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
|
|
420
|
+
return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
|
|
421
|
+
<table><thead><tr><th></th>${"${vendorHeads}"}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
|
|
422
|
+
</details>`;
|
|
423
|
+
}).join("");
|
|
424
|
+
// The closing argument: walk from open ground to a reasoned target list.
|
|
425
|
+
const openFronts = model.orderedClaimIds.filter((claimId) => {
|
|
405
426
|
const state = stateByClaim.get(claimId);
|
|
406
427
|
return state === "open" || state === "vacant";
|
|
407
|
-
})
|
|
428
|
+
});
|
|
429
|
+
const targetItems = openFronts
|
|
408
430
|
.map((claimId) => {
|
|
409
431
|
const claim = claimsById.get(claimId);
|
|
410
|
-
|
|
432
|
+
const front = frontByClaim.get(claimId);
|
|
433
|
+
if (!claim || !front)
|
|
434
|
+
return "";
|
|
435
|
+
const quietNames = front.quietVendorIds.map((id) => vendorNamesById.get(id) ?? id);
|
|
436
|
+
const anchorIntensity = anchor ? model.cell(anchor, claimId)?.intensity ?? "unobservable" : null;
|
|
437
|
+
const nearest = quietNames.length > 0
|
|
438
|
+
? `Closest contenders (quiet): ${quietNames.join(", ")}.`
|
|
439
|
+
: "Nobody even ships it quietly — vacant ground.";
|
|
440
|
+
const move = anchorIntensity === "quiet"
|
|
441
|
+
? `${e(vendorNamesById.get(anchor) ?? "The anchor")} already ships this quietly — a promote-to-loud candidate.`
|
|
442
|
+
: anchorIntensity === "absent"
|
|
443
|
+
? "Unclaimed by the anchor: a first-mover messaging opportunity if the capability is real or buildable."
|
|
444
|
+
: "";
|
|
445
|
+
return `<li><b>${e(claim.capability.split(":")[0])}</b> <span class="sum-soft">(${e(claim.icp)} · ${e(claim.pricingStructure)})</span><br>
|
|
446
|
+
No vendor is loud on this claim. ${e(nearest)} ${move}</li>`;
|
|
411
447
|
})
|
|
412
448
|
.join("");
|
|
413
|
-
const
|
|
414
|
-
.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
449
|
+
const heldFronts = anchor
|
|
450
|
+
? model.fronts.filter((front) => front.state === "owned" && front.loudVendorIds[0] === anchor)
|
|
451
|
+
: [];
|
|
452
|
+
const heldLine = heldFronts.length > 0
|
|
453
|
+
? `<p><b>Held ground:</b> ${e(vendorNamesById.get(anchor) ?? "the anchor")} is the sole loud vendor on ${heldFronts
|
|
454
|
+
.map((front) => `<i>${e(claimsById.get(front.claimId)?.capability.split(":")[0] ?? front.claimId)}</i>`)
|
|
455
|
+
.join(", ")} — positions to defend, not abandon.</p>`
|
|
456
|
+
: "";
|
|
457
|
+
const crowdLine = counts.saturated > 0
|
|
458
|
+
? `<p><b>Crowded ground:</b> ${counts.saturated} claim${counts.saturated === 1 ? " is" : "s are"} saturated (4+ vendors loud) — message budget spent there buys the least differentiation.</p>`
|
|
459
|
+
: "";
|
|
460
|
+
const takeaway = `<section>
|
|
461
|
+
<h2>Where to attack</h2>
|
|
462
|
+
<p class="lede">${openFronts.length === 0 ? "No open fronts this run — every claim has at least one loud vendor. Watch the drift between runs for windows opening." : `${openFronts.length} claim${openFronts.length === 1 ? "" : "s"} in this category ${openFronts.length === 1 ? "is" : "are"} open: buyers can be reached there without out-shouting anyone.`}</p>
|
|
463
|
+
<ul class="targets">${targetItems}</ul>
|
|
464
|
+
${heldLine}
|
|
465
|
+
${crowdLine}
|
|
466
|
+
<p class="sum-soft">These are messaging fronts, not verdicts — join the map to CRM ground truth (\`market overlay\`) for evidence-backed OCCUPY / PROMOTE / URGENT / RETREAT directives with win-rate stats.</p>
|
|
467
|
+
</section>`;
|
|
468
|
+
// Evidence grouped by vendor, collapsed: receipts on demand, not a scroll wall.
|
|
469
|
+
const appendixGroups = config.vendors
|
|
470
|
+
.map((vendor) => {
|
|
471
|
+
const items = model.orderedClaimIds.flatMap((claimId) => {
|
|
472
|
+
const obs = model.cell(vendor.id, claimId);
|
|
473
|
+
if (!obs || obs.evidence.length === 0)
|
|
474
|
+
return [];
|
|
475
|
+
return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
|
|
476
|
+
`<blockquote>“${e(evidence.text)}”</blockquote>` +
|
|
477
|
+
`<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
|
|
478
|
+
});
|
|
479
|
+
if (items.length === 0)
|
|
480
|
+
return "";
|
|
481
|
+
return `<details class="ev-group"><summary><b>${e(vendor.name)}</b> <span class="sum-soft">— ${items.length} quoted span${items.length === 1 ? "" : "s"}</span></summary>${items.join("")}</details>`;
|
|
482
|
+
})
|
|
422
483
|
.join("");
|
|
423
484
|
const vendorHeads = config.vendors
|
|
424
485
|
.map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
|
|
@@ -438,14 +499,16 @@ h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
|
438
499
|
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
439
500
|
section { margin-top:44px; }
|
|
440
501
|
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
441
|
-
.
|
|
442
|
-
.
|
|
443
|
-
.
|
|
444
|
-
.
|
|
445
|
-
.
|
|
446
|
-
.
|
|
447
|
-
.
|
|
448
|
-
.
|
|
502
|
+
details.claim-group, details.ev-group { border:1px solid var(--line); border-radius:3px; margin-top:10px; }
|
|
503
|
+
details.claim-group summary, details.ev-group summary { cursor:pointer; padding:10px 14px; font-size:14px; list-style-position:inside; }
|
|
504
|
+
details.claim-group summary:hover, details.ev-group summary:hover { background:var(--faint); }
|
|
505
|
+
details.claim-group[open] summary, details.ev-group[open] summary { border-bottom:1px solid var(--line); }
|
|
506
|
+
details.claim-group table { margin:4px 12px 12px; width:calc(100% - 24px); }
|
|
507
|
+
details.ev-group .ev { margin:0 14px; }
|
|
508
|
+
.sum-soft { color:var(--soft); font-size:12px; }
|
|
509
|
+
.lede { font-size:16px; margin-top:12px; }
|
|
510
|
+
.targets { margin-top:12px; font-size:14.5px; line-height:1.6; }
|
|
511
|
+
.targets li { margin:10px 0 10px 20px; }
|
|
449
512
|
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
450
513
|
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
451
514
|
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
@@ -506,34 +569,24 @@ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; fon
|
|
|
506
569
|
<span>extractor: ${e(set.extractor)}</span>
|
|
507
570
|
</div>
|
|
508
571
|
</header>
|
|
572
|
+
${axisHtml.strategicMap}
|
|
509
573
|
<section>
|
|
510
|
-
<h2>
|
|
511
|
-
<div class="fronts">
|
|
512
|
-
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
513
|
-
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
514
|
-
<div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
|
|
515
|
-
<div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
|
|
516
|
-
</div>
|
|
517
|
-
<ul class="openlist">${openList}</ul>
|
|
518
|
-
</section>
|
|
519
|
-
<section>
|
|
520
|
-
<h2>Claim × vendor intensity matrix</h2>
|
|
574
|
+
<h2>Claims, front by front</h2>
|
|
521
575
|
<div class="key">
|
|
522
576
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
523
577
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
524
578
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
525
579
|
<span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
|
|
526
580
|
</div>
|
|
527
|
-
|
|
528
|
-
<thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
|
|
529
|
-
<tbody>${matrixRows}</tbody>
|
|
530
|
-
</table>
|
|
581
|
+
${groupedMatrix}
|
|
531
582
|
</section>
|
|
532
|
-
${
|
|
583
|
+
${takeaway}
|
|
533
584
|
<section>
|
|
534
585
|
<h2>Evidence appendix</h2>
|
|
535
|
-
|
|
586
|
+
<p class="sum-soft">Every loud/quiet reading is grounded in a verbatim span from a stored page capture; expand a vendor for its receipts.</p>
|
|
587
|
+
${appendixGroups}
|
|
536
588
|
</section>
|
|
589
|
+
<script>window.addEventListener("beforeprint",function(){document.querySelectorAll("details").forEach(function(d){d.open=true;});});</script>
|
|
537
590
|
<footer>
|
|
538
591
|
<span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
|
|
539
592
|
<span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
2
|
+
export type ReassignObjectType = "account" | "contact" | "deal";
|
|
3
|
+
export type ReassignOptions = {
|
|
4
|
+
/** the owner whose records are being handed off */
|
|
5
|
+
fromOwnerId: string;
|
|
6
|
+
/** the receiving owner — must be a known user in the snapshot */
|
|
7
|
+
toOwnerId: string;
|
|
8
|
+
/** which object types to compile plans for (default all three) */
|
|
9
|
+
objects?: ReassignObjectType[];
|
|
10
|
+
/** extra --where scoping, AND-ed into every plan (account fields lifted) */
|
|
11
|
+
where?: string[];
|
|
12
|
+
/** exclude records tied to an open deal in this stage (see module doc) */
|
|
13
|
+
exceptDealStage?: string;
|
|
14
|
+
/** also reassign closed deals (default false: history keeps its owner) */
|
|
15
|
+
includeClosedDeals?: boolean;
|
|
16
|
+
reason?: string;
|
|
17
|
+
maxOperations?: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function buildReassignPlans(snapshot: CanonicalGtmSnapshot, options: ReassignOptions): PatchPlan[];
|
package/dist/reassign.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ownership-handoff playbook: `reassign` compiles one bulk-update-style
|
|
3
|
+
* dry-run plan PER object type (accounts, contacts, deals by default) that
|
|
4
|
+
* moves every record owned by --from to --to. It NEVER writes — each plan
|
|
5
|
+
* flows through the same plans-approve → apply gate, and because every plan
|
|
6
|
+
* carries its full filter, eligibility (extra --where scoping AND the
|
|
7
|
+
* --except-deal-stage exclusion) is re-verified per record against a FRESH
|
|
8
|
+
* snapshot at apply time, with mid-apply rechecks. A record that drifts into
|
|
9
|
+
* the exception set mid-run surfaces as a conflict, not a bad write.
|
|
10
|
+
*
|
|
11
|
+
* --except-deal-stage <stage> excludes in-flight business end to end:
|
|
12
|
+
* - deal plans drop deals in that stage (stage!=<stage>), and
|
|
13
|
+
* - ALL plans drop records whose account has an OPEN deal in that stage
|
|
14
|
+
* (accounts: openDealStages!~<stage>; deals/contacts:
|
|
15
|
+
* account.openDealStages!~<stage>).
|
|
16
|
+
*
|
|
17
|
+
* Deal plans cover OPEN deals only unless includeClosedDeals is set: closed
|
|
18
|
+
* deals keep their historical owner in a handoff.
|
|
19
|
+
*
|
|
20
|
+
* Extra --where clauses are account-scoped where needed: a clause on a field
|
|
21
|
+
* that only exists on accounts (e.g. domain~.de) is lifted to the relational
|
|
22
|
+
* pseudo-field (account.domain~.de) for contact and deal plans, so one
|
|
23
|
+
* invocation scopes all three object types consistently.
|
|
24
|
+
*/
|
|
25
|
+
import { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
|
|
26
|
+
const ALL_OBJECTS = ["account", "contact", "deal"];
|
|
27
|
+
/**
|
|
28
|
+
* Scope an extra --where clause to one object type: pass through when the
|
|
29
|
+
* field is valid there, lift account-only fields to account.<field> for
|
|
30
|
+
* contacts and deals. Unknown fields fall through to buildBulkUpdatePlan's
|
|
31
|
+
* strict validation, which throws with the valid-field list.
|
|
32
|
+
*/
|
|
33
|
+
function scopeWhere(objectType, raw) {
|
|
34
|
+
const clause = parseWhere(raw);
|
|
35
|
+
if (isFilterableField(objectType, clause.field))
|
|
36
|
+
return raw;
|
|
37
|
+
if (objectType !== "account" && isFilterableField(objectType, `account.${clause.field}`)) {
|
|
38
|
+
return `account.${raw}`;
|
|
39
|
+
}
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
export function buildReassignPlans(snapshot, options) {
|
|
43
|
+
if (!options.fromOwnerId || !options.toOwnerId) {
|
|
44
|
+
throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
|
|
45
|
+
}
|
|
46
|
+
if (options.fromOwnerId === options.toOwnerId) {
|
|
47
|
+
throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
|
|
48
|
+
}
|
|
49
|
+
// The receiving owner must exist: a typo'd --to would otherwise write an
|
|
50
|
+
// invalid owner onto every matched record.
|
|
51
|
+
if (!snapshot.users.some((user) => user.id === options.toOwnerId)) {
|
|
52
|
+
throw new Error(`reassign --to ${options.toOwnerId} is not a known user in the snapshot. Known users: ${snapshot.users
|
|
53
|
+
.map((user) => `${user.id} (${user.name})`)
|
|
54
|
+
.join(", ") || "none"}.`);
|
|
55
|
+
}
|
|
56
|
+
const objects = options.objects ?? ALL_OBJECTS;
|
|
57
|
+
for (const objectType of objects) {
|
|
58
|
+
if (!ALL_OBJECTS.includes(objectType)) {
|
|
59
|
+
throw new Error(`reassign --objects supports account, contact, deal — got "${objectType}".`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return objects.map((objectType) => {
|
|
63
|
+
const where = [`ownerId=${options.fromOwnerId}`];
|
|
64
|
+
if (objectType === "deal" && !options.includeClosedDeals) {
|
|
65
|
+
where.push("isClosed=false"); // closed deals keep their historical owner
|
|
66
|
+
}
|
|
67
|
+
for (const extra of options.where ?? []) {
|
|
68
|
+
where.push(scopeWhere(objectType, extra));
|
|
69
|
+
}
|
|
70
|
+
if (options.exceptDealStage) {
|
|
71
|
+
const stage = options.exceptDealStage;
|
|
72
|
+
if (objectType === "deal")
|
|
73
|
+
where.push(`stage!=${stage}`);
|
|
74
|
+
where.push(objectType === "account"
|
|
75
|
+
? `openDealStages!~${stage}`
|
|
76
|
+
: `account.openDealStages!~${stage}`);
|
|
77
|
+
}
|
|
78
|
+
return buildBulkUpdatePlan(snapshot, {
|
|
79
|
+
objectType,
|
|
80
|
+
where,
|
|
81
|
+
set: { ownerId: options.toOwnerId },
|
|
82
|
+
reason: options.reason ??
|
|
83
|
+
`reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
|
|
84
|
+
maxOperations: options.maxOperations,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
package/dist/suggest.js
CHANGED
|
@@ -64,11 +64,39 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
64
64
|
if (!deal) {
|
|
65
65
|
return { ...base, suggestedValue: null, confidence: "none", reason: "Deal not found in the snapshot." };
|
|
66
66
|
}
|
|
67
|
-
// Convention: "Contact Name - Company Name"
|
|
68
|
-
// independent; agreement upgrades confidence,
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
67
|
+
// Convention 1: "Contact Name - Company Name" (hyphen, en or em dash).
|
|
68
|
+
// Both signals below are independent; agreement upgrades confidence,
|
|
69
|
+
// conflict downgrades it.
|
|
70
|
+
const separator = [" - ", " – ", " — "]
|
|
71
|
+
.map((s) => ({ s, index: deal.name.indexOf(s) }))
|
|
72
|
+
.filter(({ index }) => index >= 0)
|
|
73
|
+
.sort((a, b) => a.index - b.index)[0];
|
|
74
|
+
const left = separator ? deal.name.slice(0, separator.index).trim() : deal.name;
|
|
75
|
+
const right = separator ? deal.name.slice(separator.index + separator.s.length).trim() : "";
|
|
76
|
+
// Convention 2: "Company - Deal descriptor" (e.g. "Globex – Expansion",
|
|
77
|
+
// "Hooli – New Business"). When the right side is purely deal-descriptor
|
|
78
|
+
// words, the company is on the LEFT. Only an exact account-name match
|
|
79
|
+
// counts as high; an unknown left side proposes a create — the engine
|
|
80
|
+
// never guesses at an existing record.
|
|
81
|
+
if (left && right && isDealDescriptor(right)) {
|
|
82
|
+
const leftMatch = accountsByNorm.get(normalize(left));
|
|
83
|
+
if (leftMatch) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
suggestedValue: leftMatch.id,
|
|
87
|
+
confidence: "high",
|
|
88
|
+
reason: `Deal name leads with the exact account name "${leftMatch.name}" ("${right}" is a deal descriptor, not a company).`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!contactsByName.get(normalize(left))) {
|
|
92
|
+
return {
|
|
93
|
+
...base,
|
|
94
|
+
suggestedValue: `create:${left}`,
|
|
95
|
+
confidence: "create",
|
|
96
|
+
reason: `No account named "${left}" exists ("${right}" is a deal descriptor) — approving creates the company, then links.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
72
100
|
// Signal 1: company-name match against account names.
|
|
73
101
|
let nameMatch = null;
|
|
74
102
|
let nameMatchKind = "";
|
|
@@ -135,6 +163,23 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
135
163
|
reason: `No account named "${right}" exists and contact "${left}" has none — approving creates the company, then links.`,
|
|
136
164
|
};
|
|
137
165
|
}
|
|
166
|
+
// Convention 3: "<Company> <descriptor…>" without a separator (e.g.
|
|
167
|
+
// "INITECH renewal"): strip trailing descriptor words and accept only an
|
|
168
|
+
// exact account-name match on what remains.
|
|
169
|
+
if (!separator) {
|
|
170
|
+
const words = normalize(deal.name).split(" ").filter(Boolean);
|
|
171
|
+
while (words.length > 1 && DEAL_DESCRIPTOR_WORDS.has(words[words.length - 1]))
|
|
172
|
+
words.pop();
|
|
173
|
+
const strippedMatch = words.length > 0 ? accountsByNorm.get(words.join(" ")) : undefined;
|
|
174
|
+
if (strippedMatch) {
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
suggestedValue: strippedMatch.id,
|
|
178
|
+
confidence: "high",
|
|
179
|
+
reason: `Deal name leads with the exact account name "${strippedMatch.name}" (trailing words are deal descriptors).`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
138
183
|
return {
|
|
139
184
|
...base,
|
|
140
185
|
suggestedValue: null,
|
|
@@ -144,6 +189,23 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
144
189
|
: `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
|
|
145
190
|
};
|
|
146
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Words that describe the deal rather than name a company. Used to recognize
|
|
194
|
+
* the "Company - Deal descriptor" naming convention: a segment counts as a
|
|
195
|
+
* descriptor only when EVERY word is in this list, so any real company name
|
|
196
|
+
* ("Brand New Startup") falls through to the contact/company conventions.
|
|
197
|
+
*/
|
|
198
|
+
const DEAL_DESCRIPTOR_WORDS = new Set([
|
|
199
|
+
"renewal", "expansion", "pilot", "annual", "monthly", "platform", "new", "business",
|
|
200
|
+
"add", "on", "addon", "upsell", "upgrade", "trial", "poc", "proof", "concept",
|
|
201
|
+
"subscription", "license", "licence", "contract", "opportunity", "deal", "engagement",
|
|
202
|
+
"implementation", "onboarding", "services", "inbound", "outbound",
|
|
203
|
+
"q1", "q2", "q3", "q4",
|
|
204
|
+
]);
|
|
205
|
+
function isDealDescriptor(segment) {
|
|
206
|
+
const words = normalize(segment).split(" ").filter(Boolean);
|
|
207
|
+
return words.length > 0 && words.every((word) => DEAL_DESCRIPTOR_WORDS.has(word));
|
|
208
|
+
}
|
|
147
209
|
/**
|
|
148
210
|
* Survivor selection for merge_records. Ranking is deterministic and
|
|
149
211
|
* evidence-based: most complete record first (count of populated canonical
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|
package/src/bulkUpdate.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
28
|
* and `openDealCount`.
|
|
29
29
|
*/
|
|
30
|
+
import { normalizeDomain } from "./merge.ts";
|
|
30
31
|
import { stableHash } from "./rules.ts";
|
|
31
32
|
import type {
|
|
32
33
|
CanonicalGtmSnapshot,
|
|
@@ -40,10 +41,23 @@ export type BulkUpdateOptions = {
|
|
|
40
41
|
objectType: "account" | "contact" | "deal";
|
|
41
42
|
/** raw --where expressions, AND-ed together; at least one is required */
|
|
42
43
|
where: string[];
|
|
43
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* canonical field → new value; one action only. A value of the form
|
|
46
|
+
* `from:<sourceField>` is resolved PER RECORD from the filter view at
|
|
47
|
+
* plan time (relational pseudo-fields like account.ownerId included);
|
|
48
|
+
* records whose source value is empty are skipped, not failed, and
|
|
49
|
+
* counted in the plan summary.
|
|
50
|
+
*/
|
|
44
51
|
set?: Record<string, string>;
|
|
45
52
|
/** propose archive_record instead of field writes */
|
|
46
53
|
archive?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* bypass the archive duplicate guard: by default --archive refuses when a
|
|
56
|
+
* matched account/contact shares its identity key (normalized domain /
|
|
57
|
+
* lowercased email) with another record — those are duplicates, and
|
|
58
|
+
* archiving a duplicate discards its data where merging preserves it
|
|
59
|
+
*/
|
|
60
|
+
forceArchiveDuplicates?: boolean;
|
|
47
61
|
/** propose create_task on each matched record with this subject/body text */
|
|
48
62
|
createTask?: string;
|
|
49
63
|
/** explicit preconditions (field=value), re-verified at apply time */
|
|
@@ -131,6 +145,14 @@ const VALID_FIELDS: Record<BulkUpdateOptions["objectType"], Set<string>> = {
|
|
|
131
145
|
deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
132
146
|
};
|
|
133
147
|
|
|
148
|
+
/** True when `field` is filterable for this object type (relational pseudo-fields included). */
|
|
149
|
+
export function isFilterableField(
|
|
150
|
+
objectType: BulkUpdateOptions["objectType"],
|
|
151
|
+
field: string,
|
|
152
|
+
): boolean {
|
|
153
|
+
return VALID_FIELDS[objectType].has(field);
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
function assertValidFields(objectType: BulkUpdateOptions["objectType"], clauses: WhereClause[], context: string): void {
|
|
135
157
|
for (const clause of clauses) {
|
|
136
158
|
if (!VALID_FIELDS[objectType].has(clause.field)) {
|
|
@@ -251,10 +273,25 @@ export function buildBulkUpdatePlan(
|
|
|
251
273
|
const clauses = options.where.map(parseWhere);
|
|
252
274
|
assertValidFields(options.objectType, clauses, "--where");
|
|
253
275
|
const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
|
|
254
|
-
|
|
276
|
+
// `from:<sourceField>` values resolve per record from the filter view —
|
|
277
|
+
// the source is validated with the same strictness as filters (relational
|
|
278
|
+
// pseudo-fields allowed; the WRITTEN field still must be canonical).
|
|
279
|
+
const assignments: Array<{ field: string; literal?: string; fromField?: string }> = [];
|
|
280
|
+
for (const [field, value] of Object.entries(options.set ?? {})) {
|
|
255
281
|
if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
|
|
256
282
|
throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
|
|
257
283
|
}
|
|
284
|
+
if (value.startsWith("from:")) {
|
|
285
|
+
const fromField = value.slice("from:".length);
|
|
286
|
+
if (!VALID_FIELDS[options.objectType].has(fromField)) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Cannot --set ${field}=from:${fromField} on ${options.objectType}s — unknown source field "${fromField}". Valid fields: ${[...VALID_FIELDS[options.objectType]].join(", ")}.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
assignments.push({ field, fromField });
|
|
292
|
+
} else {
|
|
293
|
+
assignments.push({ field, literal: value });
|
|
294
|
+
}
|
|
258
295
|
}
|
|
259
296
|
const views = buildViews(snapshot, options.objectType);
|
|
260
297
|
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
|
|
@@ -264,6 +301,45 @@ export function buildBulkUpdatePlan(
|
|
|
264
301
|
);
|
|
265
302
|
}
|
|
266
303
|
|
|
304
|
+
// Archive duplicate guard: archiving a record that shares its identity key
|
|
305
|
+
// with another active record discards data a merge would preserve. Refuse
|
|
306
|
+
// and point at `dedupe` unless explicitly overridden. Deals are exempt —
|
|
307
|
+
// they carry no identity key.
|
|
308
|
+
if (options.archive && options.objectType !== "deal" && !options.forceArchiveDuplicates) {
|
|
309
|
+
const keyName = options.objectType === "account" ? "domain" : "email";
|
|
310
|
+
const keyOf = (record: Record<string, unknown>): string | undefined =>
|
|
311
|
+
options.objectType === "account"
|
|
312
|
+
? normalizeDomain(record.domain as string | undefined)
|
|
313
|
+
: ((record.email as string | undefined)?.trim().toLowerCase() || undefined);
|
|
314
|
+
const allRecords = snapshot[COLLECTIONS[options.objectType]] as Array<Record<string, unknown>>;
|
|
315
|
+
const byKey = new Map<string, Array<Record<string, unknown>>>();
|
|
316
|
+
for (const record of allRecords) {
|
|
317
|
+
const key = keyOf(record);
|
|
318
|
+
if (!key) continue;
|
|
319
|
+
const existing = byKey.get(key) ?? [];
|
|
320
|
+
existing.push(record);
|
|
321
|
+
byKey.set(key, existing);
|
|
322
|
+
}
|
|
323
|
+
const collisions: string[] = [];
|
|
324
|
+
for (const { record } of matched) {
|
|
325
|
+
const key = keyOf(record);
|
|
326
|
+
if (!key) continue;
|
|
327
|
+
const others = (byKey.get(key) ?? []).filter((other) => other.id !== record.id);
|
|
328
|
+
if (others.length === 0) continue;
|
|
329
|
+
const label = (record.name as string | undefined) ?? (record.email as string | undefined) ?? "";
|
|
330
|
+
collisions.push(
|
|
331
|
+
`${options.objectType} ${record.id}${label ? ` "${label}"` : ""} shares ${keyName} "${key}" with ${others
|
|
332
|
+
.map((other) => `${other.id}${other.name ? ` "${other.name}"` : ""}`)
|
|
333
|
+
.join(", ")}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (collisions.length > 0) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Refusing to archive: ${collisions.length} matched record(s) look like duplicates of other records — archiving a duplicate DISCARDS its data, merging preserves it. Use \`fullstackgtm dedupe ${options.objectType} --key ${keyName}\` (merge_records) instead, or pass --force-archive-duplicates to archive anyway.\n - ${collisions.join("\n - ")}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
267
343
|
// Preconditions: explicit --require, plus every equality filter on a real
|
|
268
344
|
// (re-readable, non-relational) field. The premise the plan was built on
|
|
269
345
|
// is re-verified per record at apply time.
|
|
@@ -295,7 +371,9 @@ export function buildBulkUpdatePlan(
|
|
|
295
371
|
const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
|
|
296
372
|
|
|
297
373
|
const operations: PatchOperation[] = [];
|
|
298
|
-
|
|
374
|
+
// records skipped because a from:<sourceField> value was empty, per source
|
|
375
|
+
const skippedBySource = new Map<string, number>();
|
|
376
|
+
for (const { record, view } of matched) {
|
|
299
377
|
const objectId = String(record.id);
|
|
300
378
|
const groupId = `grp_${options.objectType}_${objectId}`;
|
|
301
379
|
const preconditions = preconditionSpecs.map((p) => ({
|
|
@@ -338,7 +416,29 @@ export function buildBulkUpdatePlan(
|
|
|
338
416
|
});
|
|
339
417
|
continue;
|
|
340
418
|
}
|
|
341
|
-
for
|
|
419
|
+
// Resolve every assignment for this record BEFORE emitting any of its
|
|
420
|
+
// operations: a record whose from:<sourceField> resolves empty is
|
|
421
|
+
// skipped whole (its operations share a groupId — half a record's
|
|
422
|
+
// updates is exactly what grouping exists to prevent).
|
|
423
|
+
const resolved: Array<{ field: string; value: string }> = [];
|
|
424
|
+
let emptySource: string | null = null;
|
|
425
|
+
for (const assignment of assignments) {
|
|
426
|
+
if (assignment.fromField !== undefined) {
|
|
427
|
+
const value = fieldValue(view, assignment.fromField);
|
|
428
|
+
if (value === "") {
|
|
429
|
+
emptySource = assignment.fromField;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
resolved.push({ field: assignment.field, value });
|
|
433
|
+
} else {
|
|
434
|
+
resolved.push({ field: assignment.field, value: assignment.literal! });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (emptySource !== null) {
|
|
438
|
+
skippedBySource.set(emptySource, (skippedBySource.get(emptySource) ?? 0) + 1);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
for (const { field, value } of resolved) {
|
|
342
442
|
operations.push({
|
|
343
443
|
...shared,
|
|
344
444
|
id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
|
|
@@ -360,13 +460,16 @@ export function buildBulkUpdatePlan(
|
|
|
360
460
|
if (failure) throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
|
|
361
461
|
}
|
|
362
462
|
|
|
463
|
+
const skippedText = [...skippedBySource.entries()]
|
|
464
|
+
.map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
|
|
465
|
+
.join("");
|
|
363
466
|
return {
|
|
364
|
-
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
|
|
467
|
+
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${options.objectType}:${whereText}:${action}:${operations.length}`)}`,
|
|
365
468
|
title: `Bulk update: ${options.objectType}s where ${whereText}`,
|
|
366
469
|
createdAt: snapshot.generatedAt,
|
|
367
470
|
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
368
471
|
dryRun: true,
|
|
369
|
-
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
472
|
+
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${skippedText}${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
370
473
|
findings: [],
|
|
371
474
|
operations,
|
|
372
475
|
filter: { objectType: options.objectType, where: options.where },
|