fullstackgtm 0.47.0 → 0.48.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 +38 -6
- package/INSTALL_FOR_AGENTS.md +3 -3
- package/README.md +16 -8
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +70 -1
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +76 -2
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli.js +40 -15
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/signalSources.js +2 -11
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/mcp.js +19 -15
- package/dist/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/types.d.ts +15 -0
- package/docs/api.md +20 -3
- package/docs/architecture.md +5 -2
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -2
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/fix.ts +68 -1
- package/src/cli/help.ts +83 -2
- package/src/cli/suggest.ts +58 -4
- package/src/cli.ts +38 -13
- package/src/connectors/hubspot.ts +3 -1
- package/src/connectors/salesforce.ts +3 -1
- package/src/connectors/signalSources.ts +2 -12
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +25 -0
- package/src/mcp.ts +22 -16
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/types.ts +17 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
2
|
+
export type StakeholderRole = "economic_buyer" | "decision_maker" | "technical_buyer" | "champion" | "influencer" | "unknown";
|
|
3
|
+
export type StakeholderSentiment = "positive" | "neutral" | "negative" | "unknown";
|
|
4
|
+
export type StakeholderNode = {
|
|
5
|
+
contactId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
role: StakeholderRole;
|
|
10
|
+
sentiment: StakeholderSentiment;
|
|
11
|
+
lastActivityAt?: string;
|
|
12
|
+
evidence: string[];
|
|
13
|
+
};
|
|
14
|
+
export type RelationshipMap = {
|
|
15
|
+
account: {
|
|
16
|
+
accountId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
domain?: string;
|
|
19
|
+
};
|
|
20
|
+
stakeholders: StakeholderNode[];
|
|
21
|
+
openDeals: Array<{
|
|
22
|
+
dealId: string;
|
|
23
|
+
name: string;
|
|
24
|
+
stage?: string;
|
|
25
|
+
amount?: number;
|
|
26
|
+
ownerId?: string;
|
|
27
|
+
}>;
|
|
28
|
+
gaps: string[];
|
|
29
|
+
counts: {
|
|
30
|
+
stakeholders: number;
|
|
31
|
+
openDeals: number;
|
|
32
|
+
activities: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export declare function buildRelationshipMap(snapshot: CanonicalGtmSnapshot, selector: {
|
|
36
|
+
accountId?: string;
|
|
37
|
+
domain?: string;
|
|
38
|
+
}): RelationshipMap;
|
|
39
|
+
export declare function relationshipMapToMarkdown(map: RelationshipMap): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.js";
|
|
2
|
+
function fullName(contact) {
|
|
3
|
+
return [contact.firstName, contact.lastName].filter(Boolean).join(" ") || contact.email || contact.id;
|
|
4
|
+
}
|
|
5
|
+
function inferRole(title) {
|
|
6
|
+
const t = (title ?? "").toLowerCase();
|
|
7
|
+
if (/\b(cfo|ceo|coo|cro|chief|founder|owner|president)\b/.test(t))
|
|
8
|
+
return "economic_buyer";
|
|
9
|
+
if (/\b(vp|vice president|head of|director|gm|general manager)\b/.test(t))
|
|
10
|
+
return "decision_maker";
|
|
11
|
+
if (/\b(cto|cio|security|it|engineering|architect|developer|technical|data)\b/.test(t))
|
|
12
|
+
return "technical_buyer";
|
|
13
|
+
if (/\b(champion|revops|sales ops|revenue operations|operations)\b/.test(t))
|
|
14
|
+
return "champion";
|
|
15
|
+
if (t)
|
|
16
|
+
return "influencer";
|
|
17
|
+
return "unknown";
|
|
18
|
+
}
|
|
19
|
+
function inferSentiment(text) {
|
|
20
|
+
const lower = text.toLowerCase();
|
|
21
|
+
if (/\b(blocked|concern|risk|no budget|not interested|detractor|negative|unhappy)\b/.test(lower))
|
|
22
|
+
return "negative";
|
|
23
|
+
if (/\b(champion|excited|interested|positive|approved|referred|next step|pilot)\b/.test(lower))
|
|
24
|
+
return "positive";
|
|
25
|
+
return "neutral";
|
|
26
|
+
}
|
|
27
|
+
function maxSentiment(a, b) {
|
|
28
|
+
const rank = { unknown: 0, neutral: 1, positive: 2, negative: 3 };
|
|
29
|
+
return rank[b] > rank[a] ? b : a;
|
|
30
|
+
}
|
|
31
|
+
function findAccount(snapshot, selector) {
|
|
32
|
+
if (selector.accountId)
|
|
33
|
+
return snapshot.accounts.find((account) => String(account.id) === selector.accountId) ?? null;
|
|
34
|
+
if (selector.domain) {
|
|
35
|
+
const domain = normalizeDomain(selector.domain);
|
|
36
|
+
return snapshot.accounts.find((account) => normalizeDomain(account.domain) === domain) ?? null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function buildRelationshipMap(snapshot, selector) {
|
|
41
|
+
const account = findAccount(snapshot, selector);
|
|
42
|
+
if (!account)
|
|
43
|
+
throw new Error("relationships: account not found (use --account-id or --domain)");
|
|
44
|
+
const contacts = snapshot.contacts.filter((contact) => contact.accountId === account.id);
|
|
45
|
+
const contactIds = new Set(contacts.map((contact) => contact.id));
|
|
46
|
+
const activities = snapshot.activities.filter((activity) => activity.accountId === account.id || (activity.contactId ? contactIds.has(activity.contactId) : false));
|
|
47
|
+
const evidenceByContact = new Map();
|
|
48
|
+
const sentimentByContact = new Map();
|
|
49
|
+
for (const activity of activities) {
|
|
50
|
+
if (!activity.contactId || !contactIds.has(activity.contactId))
|
|
51
|
+
continue;
|
|
52
|
+
const text = [activity.subject, activity.type, activity.occurredAt].filter(Boolean).join(" — ");
|
|
53
|
+
evidenceByContact.set(activity.contactId, [...(evidenceByContact.get(activity.contactId) ?? []), text]);
|
|
54
|
+
sentimentByContact.set(activity.contactId, maxSentiment(sentimentByContact.get(activity.contactId) ?? "unknown", inferSentiment(text)));
|
|
55
|
+
}
|
|
56
|
+
const stakeholders = contacts.map((contact) => ({
|
|
57
|
+
contactId: String(contact.id),
|
|
58
|
+
name: fullName(contact),
|
|
59
|
+
...(contact.title ? { title: contact.title } : {}),
|
|
60
|
+
...(contact.email ? { email: contact.email } : {}),
|
|
61
|
+
role: inferRole(contact.title),
|
|
62
|
+
sentiment: sentimentByContact.get(contact.id) ?? "unknown",
|
|
63
|
+
...(contact.lastActivityAt ? { lastActivityAt: contact.lastActivityAt } : {}),
|
|
64
|
+
evidence: evidenceByContact.get(contact.id) ?? [],
|
|
65
|
+
})).sort((a, b) => a.role.localeCompare(b.role) || a.name.localeCompare(b.name));
|
|
66
|
+
const openDeals = snapshot.deals.filter((deal) => deal.accountId === account.id && !deal.isClosed).map((deal) => ({
|
|
67
|
+
dealId: String(deal.id),
|
|
68
|
+
name: deal.name,
|
|
69
|
+
...(deal.stage ? { stage: deal.stage } : {}),
|
|
70
|
+
...(deal.amount !== undefined ? { amount: deal.amount } : {}),
|
|
71
|
+
...(deal.ownerId ? { ownerId: deal.ownerId } : {}),
|
|
72
|
+
}));
|
|
73
|
+
const roles = new Set(stakeholders.map((s) => s.role));
|
|
74
|
+
const gaps = [];
|
|
75
|
+
if (!roles.has("economic_buyer"))
|
|
76
|
+
gaps.push("No obvious economic buyer found from titles.");
|
|
77
|
+
if (!roles.has("decision_maker"))
|
|
78
|
+
gaps.push("No obvious decision maker found from titles.");
|
|
79
|
+
if (stakeholders.every((s) => s.sentiment === "unknown"))
|
|
80
|
+
gaps.push("No stakeholder activity evidence attached to this account.");
|
|
81
|
+
return { account: { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}) }, stakeholders, openDeals, gaps, counts: { stakeholders: stakeholders.length, openDeals: openDeals.length, activities: activities.length } };
|
|
82
|
+
}
|
|
83
|
+
export function relationshipMapToMarkdown(map) {
|
|
84
|
+
const lines = [`# Relationship map: ${map.account.name}`, "", `${map.counts.stakeholders} stakeholder(s), ${map.counts.openDeals} open deal(s), ${map.counts.activities} activity record(s).`, "", "## Stakeholders"];
|
|
85
|
+
for (const s of map.stakeholders) {
|
|
86
|
+
lines.push(`- ${s.name} — ${s.role}, ${s.sentiment}${s.title ? ` (${s.title})` : ""}`);
|
|
87
|
+
for (const evidence of s.evidence.slice(0, 3))
|
|
88
|
+
lines.push(` - evidence: ${evidence}`);
|
|
89
|
+
}
|
|
90
|
+
if (map.openDeals.length) {
|
|
91
|
+
lines.push("", "## Open deals");
|
|
92
|
+
for (const deal of map.openDeals)
|
|
93
|
+
lines.push(`- ${deal.name} (${deal.dealId})${deal.stage ? ` — ${deal.stage}` : ""}${deal.amount !== undefined ? ` — ${deal.amount}` : ""}`);
|
|
94
|
+
}
|
|
95
|
+
if (map.gaps.length) {
|
|
96
|
+
lines.push("", "## Gaps");
|
|
97
|
+
for (const gap of map.gaps)
|
|
98
|
+
lines.push(`- ${gap}`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
package/dist/route.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type AssignmentPolicy } from "./assign.ts";
|
|
2
|
+
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Lead-to-account matching and routing recipes.
|
|
5
|
+
*
|
|
6
|
+
* This is the first packaged Traction-Complete-style workflow in the OSS CLI:
|
|
7
|
+
* take ownerless/unmatched leads, deterministically match them to accounts, and
|
|
8
|
+
* emit normal approval-gated patch operations. No writes happen here.
|
|
9
|
+
*/
|
|
10
|
+
export type LeadRouteOptions = {
|
|
11
|
+
/** Match contact email domains to account domains. Default true. */
|
|
12
|
+
matchByDomain?: boolean;
|
|
13
|
+
/** Also try raw company/name fields against account names when domain misses. */
|
|
14
|
+
matchByCompanyName?: boolean;
|
|
15
|
+
/** When an ownerless contact matches an owned account, set contact ownerId to the account owner. Default true. */
|
|
16
|
+
inheritAccountOwner?: boolean;
|
|
17
|
+
/** Reassign contacts that already have an owner when the matched account owner differs. Default false. */
|
|
18
|
+
reassignExistingOwner?: boolean;
|
|
19
|
+
/** Optional fallback policy for contacts that still do not have an owner. */
|
|
20
|
+
assignmentPolicy?: AssignmentPolicy;
|
|
21
|
+
/** Refuse to build larger plans unless explicitly raised. Default 500. */
|
|
22
|
+
maxOperations?: number;
|
|
23
|
+
reason?: string;
|
|
24
|
+
};
|
|
25
|
+
export type LeadRouteCounts = {
|
|
26
|
+
contactsScanned: number;
|
|
27
|
+
/** Contacts that ended with an unambiguous account context (existing link, domain match, or company-name match). */
|
|
28
|
+
matchedContacts: number;
|
|
29
|
+
matchedExistingAccount: number;
|
|
30
|
+
matchedByDomain: number;
|
|
31
|
+
matchedByCompanyName: number;
|
|
32
|
+
ambiguousAccountMatches: number;
|
|
33
|
+
skippedFreeEmail: number;
|
|
34
|
+
linkOperations: number;
|
|
35
|
+
/** Owner set_field operations proposed (account-owner inheritance + policy fallback). */
|
|
36
|
+
ownerOperations: number;
|
|
37
|
+
/** Alias for ownerOperations, named for reports that talk about owner-assigned leads. */
|
|
38
|
+
ownerAssigned: number;
|
|
39
|
+
policyAssigned: number;
|
|
40
|
+
unmatched: number;
|
|
41
|
+
};
|
|
42
|
+
export type LeadRouteResult = {
|
|
43
|
+
plan: PatchPlan;
|
|
44
|
+
counts: LeadRouteCounts;
|
|
45
|
+
};
|
|
46
|
+
export declare function buildLeadRoutePlan(snapshot: CanonicalGtmSnapshot, options?: LeadRouteOptions): LeadRouteResult;
|
package/dist/route.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { resolveAssignment } from "./assign.js";
|
|
2
|
+
import { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.js";
|
|
3
|
+
import { normalizeDomain } from "./merge.js";
|
|
4
|
+
import { stableHash } from "./rules.js";
|
|
5
|
+
function norm(value) {
|
|
6
|
+
if (value === undefined || value === null)
|
|
7
|
+
return undefined;
|
|
8
|
+
const out = String(value).trim().toLowerCase();
|
|
9
|
+
return out || undefined;
|
|
10
|
+
}
|
|
11
|
+
function contactEmailDomain(contact) {
|
|
12
|
+
const email = norm(contact.email);
|
|
13
|
+
if (!email || !email.includes("@"))
|
|
14
|
+
return undefined;
|
|
15
|
+
return normalizeDomain(email.slice(email.lastIndexOf("@") + 1));
|
|
16
|
+
}
|
|
17
|
+
function rawString(record, keys) {
|
|
18
|
+
if (!record.raw || typeof record.raw !== "object")
|
|
19
|
+
return undefined;
|
|
20
|
+
const raw = record.raw;
|
|
21
|
+
for (const key of keys) {
|
|
22
|
+
const value = raw[key];
|
|
23
|
+
if (typeof value === "string" && value.trim())
|
|
24
|
+
return value.trim();
|
|
25
|
+
if (value && typeof value === "object") {
|
|
26
|
+
const nested = value;
|
|
27
|
+
for (const nestedKey of ["value", "name", "label"]) {
|
|
28
|
+
if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) {
|
|
29
|
+
return String(nested[nestedKey]).trim();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function companyNameFor(contact) {
|
|
37
|
+
return rawString(contact, ["company", "companyName", "company_name", "account", "accountName", "account_name"]);
|
|
38
|
+
}
|
|
39
|
+
function uniqueAccount(matches) {
|
|
40
|
+
const byId = new Map(matches.map((account) => [String(account.id), account]));
|
|
41
|
+
const unique = [...byId.values()];
|
|
42
|
+
if (unique.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
if (unique.length > 1)
|
|
45
|
+
return "ambiguous";
|
|
46
|
+
return unique[0];
|
|
47
|
+
}
|
|
48
|
+
function employeeBand(employeeCount) {
|
|
49
|
+
if (employeeCount === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (employeeCount <= 10)
|
|
52
|
+
return "1-10";
|
|
53
|
+
if (employeeCount <= 50)
|
|
54
|
+
return "11-50";
|
|
55
|
+
if (employeeCount <= 200)
|
|
56
|
+
return "51-200";
|
|
57
|
+
if (employeeCount <= 1000)
|
|
58
|
+
return "201-1000";
|
|
59
|
+
return "1001+";
|
|
60
|
+
}
|
|
61
|
+
function routeContext(contact, account) {
|
|
62
|
+
const geo = norm(rawString(contact, ["country", "countryCode", "country_code", "state", "region"])) ??
|
|
63
|
+
norm(rawString(account ?? { raw: undefined }, ["country", "countryCode", "country_code", "state", "region"]));
|
|
64
|
+
const department = norm(rawString(contact, ["department", "jobDepartment", "job_department"]));
|
|
65
|
+
return {
|
|
66
|
+
geo,
|
|
67
|
+
industry: norm(account?.industry),
|
|
68
|
+
employeeBand: employeeBand(account?.employeeCount),
|
|
69
|
+
department,
|
|
70
|
+
title: norm(contact.title),
|
|
71
|
+
accountOwnerId: account?.ownerId,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function activeOwnerIds(snapshot) {
|
|
75
|
+
const ids = new Set();
|
|
76
|
+
for (const user of snapshot.users) {
|
|
77
|
+
if (user.active === false)
|
|
78
|
+
continue;
|
|
79
|
+
ids.add(String(user.id));
|
|
80
|
+
if (user.crmId)
|
|
81
|
+
ids.add(String(user.crmId));
|
|
82
|
+
}
|
|
83
|
+
return ids;
|
|
84
|
+
}
|
|
85
|
+
export function buildLeadRoutePlan(snapshot, options = {}) {
|
|
86
|
+
const matchByDomain = options.matchByDomain ?? true;
|
|
87
|
+
const matchByCompanyName = options.matchByCompanyName ?? false;
|
|
88
|
+
const inheritAccountOwner = options.inheritAccountOwner ?? true;
|
|
89
|
+
const reassignExistingOwner = options.reassignExistingOwner ?? false;
|
|
90
|
+
const maxOperations = options.maxOperations ?? 500;
|
|
91
|
+
const accountsByDomain = new Map();
|
|
92
|
+
const accountsByName = new Map();
|
|
93
|
+
for (const account of snapshot.accounts) {
|
|
94
|
+
const domain = normalizeDomain(account.domain);
|
|
95
|
+
if (domain)
|
|
96
|
+
accountsByDomain.set(domain, [...(accountsByDomain.get(domain) ?? []), account]);
|
|
97
|
+
const name = norm(account.name);
|
|
98
|
+
if (name)
|
|
99
|
+
accountsByName.set(name, [...(accountsByName.get(name) ?? []), account]);
|
|
100
|
+
}
|
|
101
|
+
const operations = [];
|
|
102
|
+
const counts = {
|
|
103
|
+
contactsScanned: 0,
|
|
104
|
+
matchedContacts: 0,
|
|
105
|
+
matchedExistingAccount: 0,
|
|
106
|
+
matchedByDomain: 0,
|
|
107
|
+
matchedByCompanyName: 0,
|
|
108
|
+
ambiguousAccountMatches: 0,
|
|
109
|
+
skippedFreeEmail: 0,
|
|
110
|
+
linkOperations: 0,
|
|
111
|
+
ownerOperations: 0,
|
|
112
|
+
ownerAssigned: 0,
|
|
113
|
+
policyAssigned: 0,
|
|
114
|
+
unmatched: 0,
|
|
115
|
+
};
|
|
116
|
+
const knownOwners = activeOwnerIds(snapshot);
|
|
117
|
+
let policyAssignmentIndex = 0;
|
|
118
|
+
for (const contact of snapshot.contacts) {
|
|
119
|
+
counts.contactsScanned += 1;
|
|
120
|
+
const currentAccount = contact.accountId ? snapshot.accounts.find((account) => account.id === contact.accountId) : undefined;
|
|
121
|
+
let matchedAccount = currentAccount;
|
|
122
|
+
let matchRule = currentAccount ? "existing" : undefined;
|
|
123
|
+
if (currentAccount)
|
|
124
|
+
counts.matchedExistingAccount += 1;
|
|
125
|
+
let skippedFreeEmail = false;
|
|
126
|
+
if (!matchedAccount && matchByDomain) {
|
|
127
|
+
const domain = contactEmailDomain(contact);
|
|
128
|
+
if (domain && FREE_EMAIL_DOMAINS.has(domain)) {
|
|
129
|
+
counts.skippedFreeEmail += 1;
|
|
130
|
+
skippedFreeEmail = true;
|
|
131
|
+
}
|
|
132
|
+
else if (domain) {
|
|
133
|
+
const match = uniqueAccount(accountsByDomain.get(domain) ?? []);
|
|
134
|
+
if (match === "ambiguous")
|
|
135
|
+
counts.ambiguousAccountMatches += 1;
|
|
136
|
+
else if (match) {
|
|
137
|
+
matchedAccount = match;
|
|
138
|
+
matchRule = "domain";
|
|
139
|
+
counts.matchedByDomain += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!matchedAccount && matchByCompanyName && !skippedFreeEmail) {
|
|
144
|
+
const company = norm(companyNameFor(contact));
|
|
145
|
+
if (company && (accountsByName.get(company) ?? []).length > 0) {
|
|
146
|
+
// Name-only account matches are not safe enough to auto-link. Keep the
|
|
147
|
+
// contact unmatched and surface the skipped match as ambiguous.
|
|
148
|
+
counts.ambiguousAccountMatches += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (matchedAccount)
|
|
152
|
+
counts.matchedContacts += 1;
|
|
153
|
+
const contactOps = [];
|
|
154
|
+
if (matchedAccount && !contact.accountId) {
|
|
155
|
+
contactOps.push({
|
|
156
|
+
id: `op_${stableHash(`route:link:${contact.id}:${matchedAccount.id}`)}`,
|
|
157
|
+
objectType: "contact",
|
|
158
|
+
objectId: String(contact.id),
|
|
159
|
+
operation: "link_record",
|
|
160
|
+
field: "accountId",
|
|
161
|
+
beforeValue: contact.accountId ?? null,
|
|
162
|
+
afterValue: String(matchedAccount.id),
|
|
163
|
+
reason: options.reason ?? `Lead-to-account match by ${matchRule}: link contact to account ${matchedAccount.name} (${matchedAccount.id}).`,
|
|
164
|
+
riskLevel: "medium",
|
|
165
|
+
approvalRequired: true,
|
|
166
|
+
sourceRuleOrPolicy: "route.lead_to_account",
|
|
167
|
+
});
|
|
168
|
+
counts.linkOperations += 1;
|
|
169
|
+
}
|
|
170
|
+
let ownerId = null;
|
|
171
|
+
let ownerRule;
|
|
172
|
+
if (inheritAccountOwner &&
|
|
173
|
+
matchedAccount?.ownerId &&
|
|
174
|
+
matchedAccount.ownerId !== contact.ownerId &&
|
|
175
|
+
(!contact.ownerId || reassignExistingOwner) &&
|
|
176
|
+
knownOwners.has(matchedAccount.ownerId)) {
|
|
177
|
+
ownerId = matchedAccount.ownerId;
|
|
178
|
+
ownerRule = "account-owner";
|
|
179
|
+
}
|
|
180
|
+
else if (options.assignmentPolicy && !contact.ownerId) {
|
|
181
|
+
const assigned = resolveAssignment(options.assignmentPolicy, routeContext(contact, matchedAccount), policyAssignmentIndex, knownOwners);
|
|
182
|
+
policyAssignmentIndex += 1;
|
|
183
|
+
ownerId = assigned.ownerId;
|
|
184
|
+
ownerRule = assigned.rule;
|
|
185
|
+
if (ownerId)
|
|
186
|
+
counts.policyAssigned += 1;
|
|
187
|
+
}
|
|
188
|
+
if (ownerId && ownerId !== contact.ownerId) {
|
|
189
|
+
contactOps.push({
|
|
190
|
+
id: `op_${stableHash(`route:owner:${contact.id}:${ownerId}`)}`,
|
|
191
|
+
objectType: "contact",
|
|
192
|
+
objectId: String(contact.id),
|
|
193
|
+
operation: "set_field",
|
|
194
|
+
field: "ownerId",
|
|
195
|
+
beforeValue: contact.ownerId ?? null,
|
|
196
|
+
afterValue: ownerId,
|
|
197
|
+
reason: options.reason ?? `Route contact owner by ${ownerRule ?? "policy"}${matchedAccount ? ` after matching account ${matchedAccount.name}` : ""}.`,
|
|
198
|
+
riskLevel: "medium",
|
|
199
|
+
approvalRequired: true,
|
|
200
|
+
sourceRuleOrPolicy: "route.owner_assignment",
|
|
201
|
+
});
|
|
202
|
+
counts.ownerOperations += 1;
|
|
203
|
+
counts.ownerAssigned = counts.ownerOperations;
|
|
204
|
+
}
|
|
205
|
+
if (contactOps.length > 1) {
|
|
206
|
+
const groupId = `grp_route_${contact.id}`;
|
|
207
|
+
for (const operation of contactOps)
|
|
208
|
+
operation.groupId = groupId;
|
|
209
|
+
}
|
|
210
|
+
operations.push(...contactOps);
|
|
211
|
+
if (!matchedAccount)
|
|
212
|
+
counts.unmatched += 1;
|
|
213
|
+
}
|
|
214
|
+
if (operations.length > maxOperations) {
|
|
215
|
+
throw new Error(`route would create ${operations.length} operations — above the ${maxOperations}-operation safety cap. Raise --max-operations after reviewing scope.`);
|
|
216
|
+
}
|
|
217
|
+
const plan = {
|
|
218
|
+
id: `patch_plan_${stableHash(`route:${snapshot.provider}:${snapshot.generatedAt}:${operations.map((op) => op.id).join(",")}`)}`,
|
|
219
|
+
title: "Route leads: match contacts to accounts and owners",
|
|
220
|
+
createdAt: snapshot.generatedAt,
|
|
221
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
222
|
+
dryRun: true,
|
|
223
|
+
summary: `${counts.matchedContacts} matched contact(s), ${counts.linkOperations} lead-to-account link(s), ${counts.ownerOperations} owner assignment(s), ${counts.ambiguousAccountMatches} ambiguous match(es), ${counts.unmatched} unmatched contact(s). Dry-run only; approve before apply.`,
|
|
224
|
+
findings: [],
|
|
225
|
+
operations,
|
|
226
|
+
};
|
|
227
|
+
return { plan, counts };
|
|
228
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -34,4 +34,5 @@ export declare const duplicateOpenDealRule: GtmAuditRule;
|
|
|
34
34
|
export declare const activeDealAccountWithoutContactsRule: GtmAuditRule;
|
|
35
35
|
export declare const closingSoonInactiveRule: GtmAuditRule;
|
|
36
36
|
export declare const accountSingleSourceRule: GtmAuditRule;
|
|
37
|
+
export declare function isFindingsOnlyRule(rule: GtmAuditRule): boolean;
|
|
37
38
|
export declare const builtinAuditRules: GtmAuditRule[];
|