fullstackgtm 0.47.0 → 0.49.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 +76 -6
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +15 -6
- package/README.md +28 -9
- package/SECURITY.md +27 -3
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +90 -8
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +81 -3
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +41 -16
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/prospectSources.js +4 -11
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/signalSources.js +2 -11
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- 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 +9 -4
- package/dist/index.js +8 -3
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +86 -130
- package/dist/planStore.d.ts +28 -1
- package/dist/planStore.js +195 -49
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- 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/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +16 -1
- package/docs/api.md +49 -5
- package/docs/architecture.md +18 -4
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +9 -3
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +96 -8
- package/src/cli/help.ts +88 -3
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/suggest.ts +58 -4
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +39 -14
- package/src/config.ts +39 -1
- package/src/connectors/hubspot.ts +3 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +15 -6
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/signalSources.ts +2 -12
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +29 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +115 -152
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/secureFile.ts +169 -0
- package/src/types.ts +18 -0
package/src/route.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { resolveAssignment, type AssignmentContext, type AssignmentPolicy } from "./assign.ts";
|
|
2
|
+
import { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
|
|
3
|
+
import { normalizeDomain } from "./merge.ts";
|
|
4
|
+
import { stableHash } from "./rules.ts";
|
|
5
|
+
import type { CanonicalAccount, CanonicalContact, CanonicalGtmSnapshot, PatchOperation, PatchPlan } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lead-to-account matching and routing recipes.
|
|
9
|
+
*
|
|
10
|
+
* This is the first packaged Traction-Complete-style workflow in the OSS CLI:
|
|
11
|
+
* take ownerless/unmatched leads, deterministically match them to accounts, and
|
|
12
|
+
* emit normal approval-gated patch operations. No writes happen here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type LeadRouteOptions = {
|
|
16
|
+
/** Match contact email domains to account domains. Default true. */
|
|
17
|
+
matchByDomain?: boolean;
|
|
18
|
+
/** Also try raw company/name fields against account names when domain misses. */
|
|
19
|
+
matchByCompanyName?: boolean;
|
|
20
|
+
/** When an ownerless contact matches an owned account, set contact ownerId to the account owner. Default true. */
|
|
21
|
+
inheritAccountOwner?: boolean;
|
|
22
|
+
/** Reassign contacts that already have an owner when the matched account owner differs. Default false. */
|
|
23
|
+
reassignExistingOwner?: boolean;
|
|
24
|
+
/** Optional fallback policy for contacts that still do not have an owner. */
|
|
25
|
+
assignmentPolicy?: AssignmentPolicy;
|
|
26
|
+
/** Refuse to build larger plans unless explicitly raised. Default 500. */
|
|
27
|
+
maxOperations?: number;
|
|
28
|
+
reason?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type LeadRouteCounts = {
|
|
32
|
+
contactsScanned: number;
|
|
33
|
+
/** Contacts that ended with an unambiguous account context (existing link, domain match, or company-name match). */
|
|
34
|
+
matchedContacts: number;
|
|
35
|
+
matchedExistingAccount: number;
|
|
36
|
+
matchedByDomain: number;
|
|
37
|
+
matchedByCompanyName: number;
|
|
38
|
+
ambiguousAccountMatches: number;
|
|
39
|
+
skippedFreeEmail: number;
|
|
40
|
+
linkOperations: number;
|
|
41
|
+
/** Owner set_field operations proposed (account-owner inheritance + policy fallback). */
|
|
42
|
+
ownerOperations: number;
|
|
43
|
+
/** Alias for ownerOperations, named for reports that talk about owner-assigned leads. */
|
|
44
|
+
ownerAssigned: number;
|
|
45
|
+
policyAssigned: number;
|
|
46
|
+
unmatched: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type LeadRouteResult = {
|
|
50
|
+
plan: PatchPlan;
|
|
51
|
+
counts: LeadRouteCounts;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function norm(value: unknown): string | undefined {
|
|
55
|
+
if (value === undefined || value === null) return undefined;
|
|
56
|
+
const out = String(value).trim().toLowerCase();
|
|
57
|
+
return out || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function contactEmailDomain(contact: CanonicalContact): string | undefined {
|
|
61
|
+
const email = norm(contact.email);
|
|
62
|
+
if (!email || !email.includes("@")) return undefined;
|
|
63
|
+
return normalizeDomain(email.slice(email.lastIndexOf("@") + 1));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rawString(record: { raw?: unknown }, keys: string[]): string | undefined {
|
|
67
|
+
if (!record.raw || typeof record.raw !== "object") return undefined;
|
|
68
|
+
const raw = record.raw as Record<string, unknown>;
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
const value = raw[key];
|
|
71
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
72
|
+
if (value && typeof value === "object") {
|
|
73
|
+
const nested = value as Record<string, unknown>;
|
|
74
|
+
for (const nestedKey of ["value", "name", "label"]) {
|
|
75
|
+
if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) {
|
|
76
|
+
return String(nested[nestedKey]).trim();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function companyNameFor(contact: CanonicalContact): string | undefined {
|
|
85
|
+
return rawString(contact, ["company", "companyName", "company_name", "account", "accountName", "account_name"]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function uniqueAccount(matches: CanonicalAccount[]): CanonicalAccount | "ambiguous" | null {
|
|
89
|
+
const byId = new Map(matches.map((account) => [String(account.id), account]));
|
|
90
|
+
const unique = [...byId.values()];
|
|
91
|
+
if (unique.length === 0) return null;
|
|
92
|
+
if (unique.length > 1) return "ambiguous";
|
|
93
|
+
return unique[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function employeeBand(employeeCount: number | undefined): string | undefined {
|
|
97
|
+
if (employeeCount === undefined) return undefined;
|
|
98
|
+
if (employeeCount <= 10) return "1-10";
|
|
99
|
+
if (employeeCount <= 50) return "11-50";
|
|
100
|
+
if (employeeCount <= 200) return "51-200";
|
|
101
|
+
if (employeeCount <= 1000) return "201-1000";
|
|
102
|
+
return "1001+";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function routeContext(contact: CanonicalContact, account?: CanonicalAccount): AssignmentContext {
|
|
106
|
+
const geo = norm(rawString(contact, ["country", "countryCode", "country_code", "state", "region"])) ??
|
|
107
|
+
norm(rawString(account ?? { raw: undefined }, ["country", "countryCode", "country_code", "state", "region"]));
|
|
108
|
+
const department = norm(rawString(contact, ["department", "jobDepartment", "job_department"]));
|
|
109
|
+
return {
|
|
110
|
+
geo,
|
|
111
|
+
industry: norm(account?.industry),
|
|
112
|
+
employeeBand: employeeBand(account?.employeeCount),
|
|
113
|
+
department,
|
|
114
|
+
title: norm(contact.title),
|
|
115
|
+
accountOwnerId: account?.ownerId,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function activeOwnerIds(snapshot: CanonicalGtmSnapshot): Set<string> {
|
|
120
|
+
const ids = new Set<string>();
|
|
121
|
+
for (const user of snapshot.users) {
|
|
122
|
+
if (user.active === false) continue;
|
|
123
|
+
ids.add(String(user.id));
|
|
124
|
+
if (user.crmId) ids.add(String(user.crmId));
|
|
125
|
+
}
|
|
126
|
+
return ids;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildLeadRoutePlan(snapshot: CanonicalGtmSnapshot, options: LeadRouteOptions = {}): LeadRouteResult {
|
|
130
|
+
const matchByDomain = options.matchByDomain ?? true;
|
|
131
|
+
const matchByCompanyName = options.matchByCompanyName ?? false;
|
|
132
|
+
const inheritAccountOwner = options.inheritAccountOwner ?? true;
|
|
133
|
+
const reassignExistingOwner = options.reassignExistingOwner ?? false;
|
|
134
|
+
const maxOperations = options.maxOperations ?? 500;
|
|
135
|
+
|
|
136
|
+
const accountsByDomain = new Map<string, CanonicalAccount[]>();
|
|
137
|
+
const accountsByName = new Map<string, CanonicalAccount[]>();
|
|
138
|
+
for (const account of snapshot.accounts) {
|
|
139
|
+
const domain = normalizeDomain(account.domain);
|
|
140
|
+
if (domain) accountsByDomain.set(domain, [...(accountsByDomain.get(domain) ?? []), account]);
|
|
141
|
+
const name = norm(account.name);
|
|
142
|
+
if (name) accountsByName.set(name, [...(accountsByName.get(name) ?? []), account]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const operations: PatchOperation[] = [];
|
|
146
|
+
const counts: LeadRouteCounts = {
|
|
147
|
+
contactsScanned: 0,
|
|
148
|
+
matchedContacts: 0,
|
|
149
|
+
matchedExistingAccount: 0,
|
|
150
|
+
matchedByDomain: 0,
|
|
151
|
+
matchedByCompanyName: 0,
|
|
152
|
+
ambiguousAccountMatches: 0,
|
|
153
|
+
skippedFreeEmail: 0,
|
|
154
|
+
linkOperations: 0,
|
|
155
|
+
ownerOperations: 0,
|
|
156
|
+
ownerAssigned: 0,
|
|
157
|
+
policyAssigned: 0,
|
|
158
|
+
unmatched: 0,
|
|
159
|
+
};
|
|
160
|
+
const knownOwners = activeOwnerIds(snapshot);
|
|
161
|
+
let policyAssignmentIndex = 0;
|
|
162
|
+
|
|
163
|
+
for (const contact of snapshot.contacts) {
|
|
164
|
+
counts.contactsScanned += 1;
|
|
165
|
+
const currentAccount = contact.accountId ? snapshot.accounts.find((account) => account.id === contact.accountId) : undefined;
|
|
166
|
+
let matchedAccount: CanonicalAccount | undefined = currentAccount;
|
|
167
|
+
let matchRule: "existing" | "domain" | "company-name" | undefined = currentAccount ? "existing" : undefined;
|
|
168
|
+
if (currentAccount) counts.matchedExistingAccount += 1;
|
|
169
|
+
|
|
170
|
+
let skippedFreeEmail = false;
|
|
171
|
+
if (!matchedAccount && matchByDomain) {
|
|
172
|
+
const domain = contactEmailDomain(contact);
|
|
173
|
+
if (domain && FREE_EMAIL_DOMAINS.has(domain)) {
|
|
174
|
+
counts.skippedFreeEmail += 1;
|
|
175
|
+
skippedFreeEmail = true;
|
|
176
|
+
} else if (domain) {
|
|
177
|
+
const match = uniqueAccount(accountsByDomain.get(domain) ?? []);
|
|
178
|
+
if (match === "ambiguous") counts.ambiguousAccountMatches += 1;
|
|
179
|
+
else if (match) {
|
|
180
|
+
matchedAccount = match;
|
|
181
|
+
matchRule = "domain";
|
|
182
|
+
counts.matchedByDomain += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!matchedAccount && matchByCompanyName && !skippedFreeEmail) {
|
|
188
|
+
const company = norm(companyNameFor(contact));
|
|
189
|
+
if (company && (accountsByName.get(company) ?? []).length > 0) {
|
|
190
|
+
// Name-only account matches are not safe enough to auto-link. Keep the
|
|
191
|
+
// contact unmatched and surface the skipped match as ambiguous.
|
|
192
|
+
counts.ambiguousAccountMatches += 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (matchedAccount) counts.matchedContacts += 1;
|
|
197
|
+
|
|
198
|
+
const contactOps: PatchOperation[] = [];
|
|
199
|
+
if (matchedAccount && !contact.accountId) {
|
|
200
|
+
contactOps.push({
|
|
201
|
+
id: `op_${stableHash(`route:link:${contact.id}:${matchedAccount.id}`)}`,
|
|
202
|
+
objectType: "contact",
|
|
203
|
+
objectId: String(contact.id),
|
|
204
|
+
operation: "link_record",
|
|
205
|
+
field: "accountId",
|
|
206
|
+
beforeValue: contact.accountId ?? null,
|
|
207
|
+
afterValue: String(matchedAccount.id),
|
|
208
|
+
reason: options.reason ?? `Lead-to-account match by ${matchRule}: link contact to account ${matchedAccount.name} (${matchedAccount.id}).`,
|
|
209
|
+
riskLevel: "medium",
|
|
210
|
+
approvalRequired: true,
|
|
211
|
+
sourceRuleOrPolicy: "route.lead_to_account",
|
|
212
|
+
});
|
|
213
|
+
counts.linkOperations += 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let ownerId: string | null = null;
|
|
217
|
+
let ownerRule: string | undefined;
|
|
218
|
+
if (
|
|
219
|
+
inheritAccountOwner &&
|
|
220
|
+
matchedAccount?.ownerId &&
|
|
221
|
+
matchedAccount.ownerId !== contact.ownerId &&
|
|
222
|
+
(!contact.ownerId || reassignExistingOwner) &&
|
|
223
|
+
knownOwners.has(matchedAccount.ownerId)
|
|
224
|
+
) {
|
|
225
|
+
ownerId = matchedAccount.ownerId;
|
|
226
|
+
ownerRule = "account-owner";
|
|
227
|
+
} else if (options.assignmentPolicy && !contact.ownerId) {
|
|
228
|
+
const assigned = resolveAssignment(options.assignmentPolicy, routeContext(contact, matchedAccount), policyAssignmentIndex, knownOwners);
|
|
229
|
+
policyAssignmentIndex += 1;
|
|
230
|
+
ownerId = assigned.ownerId;
|
|
231
|
+
ownerRule = assigned.rule;
|
|
232
|
+
if (ownerId) counts.policyAssigned += 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (ownerId && ownerId !== contact.ownerId) {
|
|
236
|
+
contactOps.push({
|
|
237
|
+
id: `op_${stableHash(`route:owner:${contact.id}:${ownerId}`)}`,
|
|
238
|
+
objectType: "contact",
|
|
239
|
+
objectId: String(contact.id),
|
|
240
|
+
operation: "set_field",
|
|
241
|
+
field: "ownerId",
|
|
242
|
+
beforeValue: contact.ownerId ?? null,
|
|
243
|
+
afterValue: ownerId,
|
|
244
|
+
reason: options.reason ?? `Route contact owner by ${ownerRule ?? "policy"}${matchedAccount ? ` after matching account ${matchedAccount.name}` : ""}.`,
|
|
245
|
+
riskLevel: "medium",
|
|
246
|
+
approvalRequired: true,
|
|
247
|
+
sourceRuleOrPolicy: "route.owner_assignment",
|
|
248
|
+
});
|
|
249
|
+
counts.ownerOperations += 1;
|
|
250
|
+
counts.ownerAssigned = counts.ownerOperations;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (contactOps.length > 1) {
|
|
254
|
+
const groupId = `grp_route_${contact.id}`;
|
|
255
|
+
for (const operation of contactOps) operation.groupId = groupId;
|
|
256
|
+
}
|
|
257
|
+
operations.push(...contactOps);
|
|
258
|
+
|
|
259
|
+
if (!matchedAccount) counts.unmatched += 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (operations.length > maxOperations) {
|
|
263
|
+
throw new Error(`route would create ${operations.length} operations — above the ${maxOperations}-operation safety cap. Raise --max-operations after reviewing scope.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const plan: PatchPlan = {
|
|
267
|
+
id: `patch_plan_${stableHash(`route:${snapshot.provider}:${snapshot.generatedAt}:${operations.map((op) => op.id).join(",")}`)}`,
|
|
268
|
+
title: "Route leads: match contacts to accounts and owners",
|
|
269
|
+
createdAt: snapshot.generatedAt,
|
|
270
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
271
|
+
dryRun: true,
|
|
272
|
+
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.`,
|
|
273
|
+
findings: [],
|
|
274
|
+
operations,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return { plan, counts };
|
|
278
|
+
}
|
package/src/rules.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
CanonicalGtmSnapshot,
|
|
8
8
|
GtmAuditRule,
|
|
9
9
|
GtmSnapshotIndex,
|
|
10
|
+
PatchPlanAssumption,
|
|
10
11
|
RiskLevel,
|
|
11
12
|
} from "./types.ts";
|
|
12
13
|
|
|
@@ -50,6 +51,109 @@ export function patchOperationId(ruleId: string, objectId: string) {
|
|
|
50
51
|
return `op_${stableHash(`${ruleId}:${objectId}`)}`;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
|
|
55
|
+
const OPEN_DEAL_FLAGS_ASSUMPTION: PatchPlanAssumption = {
|
|
56
|
+
id: "open-deal-derived-from-closed-won-flags",
|
|
57
|
+
text: "Derived open-deal status from canonical isClosed/isWon flags; a deal is open when neither flag is true.",
|
|
58
|
+
source: "CanonicalDeal.isClosed/isWon",
|
|
59
|
+
confidence: "derived",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const MISSING_CLOSED_WON_FLAGS_ASSUMPTION: PatchPlanAssumption = {
|
|
63
|
+
id: "missing-closed-won-flags-treated-open",
|
|
64
|
+
text: "Treated deals with missing isClosed/isWon flags as open so hygiene rules do not silently ignore active pipeline.",
|
|
65
|
+
source: "CanonicalDeal.isClosed/isWon",
|
|
66
|
+
confidence: "heuristic",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const ORPHAN_ACCOUNT_RELATIONSHIP_ASSUMPTION: PatchPlanAssumption = {
|
|
70
|
+
id: "account-without-contacts-or-deals-treated-orphan",
|
|
71
|
+
text: "Treated an account as orphaned when the snapshot has no contacts and no deals linked to that account.",
|
|
72
|
+
source: "snapshot account/contact/deal associations",
|
|
73
|
+
confidence: "derived",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const DEAL_OWNER_POLICY_ASSUMPTION: PatchPlanAssumption = {
|
|
77
|
+
id: "deal-owner-required-by-policy",
|
|
78
|
+
text: "Treated a missing or unknown deal owner as invalid when requireDealOwner policy is enabled.",
|
|
79
|
+
source: "GtmPolicy.requireDealOwner",
|
|
80
|
+
confidence: "heuristic",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const DEAL_ACCOUNT_POLICY_ASSUMPTION: PatchPlanAssumption = {
|
|
84
|
+
id: "deal-account-required-by-policy",
|
|
85
|
+
text: "Treated a missing or unknown deal account link as invalid when requireAccountForDeal policy is enabled.",
|
|
86
|
+
source: "GtmPolicy.requireAccountForDeal",
|
|
87
|
+
confidence: "heuristic",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const DEAL_AMOUNT_POLICY_ASSUMPTION: PatchPlanAssumption = {
|
|
91
|
+
id: "open-deal-amount-required-by-policy",
|
|
92
|
+
text: "Treated open deals with missing or zero amount as forecast-risk findings unless requireDealAmount is disabled.",
|
|
93
|
+
source: "GtmPolicy.requireDealAmount",
|
|
94
|
+
confidence: "heuristic",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const DUPLICATE_ACCOUNT_DOMAIN_ASSUMPTION: PatchPlanAssumption = {
|
|
98
|
+
id: "same-normalized-domain-duplicate-account",
|
|
99
|
+
text: "Treated accounts with the same normalized domain as duplicate-account candidates.",
|
|
100
|
+
source: "CanonicalAccount.domain",
|
|
101
|
+
confidence: "heuristic",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const DUPLICATE_CONTACT_EMAIL_ASSUMPTION: PatchPlanAssumption = {
|
|
105
|
+
id: "same-email-duplicate-contact",
|
|
106
|
+
text: "Treated contacts with the same lowercased email address as duplicate-contact candidates.",
|
|
107
|
+
source: "CanonicalContact.email",
|
|
108
|
+
confidence: "heuristic",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const DUPLICATE_OPEN_DEAL_ASSUMPTION: PatchPlanAssumption = {
|
|
112
|
+
id: "same-account-and-name-duplicate-open-deal",
|
|
113
|
+
text: "Treated open deals with the same normalized name on the same account scope as duplicate-opportunity candidates.",
|
|
114
|
+
source: "CanonicalDeal.accountId/name",
|
|
115
|
+
confidence: "heuristic",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const ACCOUNT_WITH_OPEN_DEAL_NEEDS_CONTACT_ASSUMPTION: PatchPlanAssumption = {
|
|
119
|
+
id: "open-pipeline-account-needs-contact",
|
|
120
|
+
text: "Treated accounts with open pipeline but no linked contacts as coverage gaps requiring a buying-committee contact.",
|
|
121
|
+
source: "snapshot account/contact/deal associations",
|
|
122
|
+
confidence: "heuristic",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const SINGLE_SOURCE_ACCOUNT_ASSUMPTION: PatchPlanAssumption = {
|
|
126
|
+
id: "single-source-account-is-cross-system-gap",
|
|
127
|
+
text: "Treated an account present in only one connected system as a cross-system reconciliation gap.",
|
|
128
|
+
source: "ProviderIdentity.provider",
|
|
129
|
+
confidence: "heuristic",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function staleDealPolicyAssumption(days: number): PatchPlanAssumption {
|
|
133
|
+
return {
|
|
134
|
+
id: "stale-deal-days-policy",
|
|
135
|
+
text: `Treated open deals with no recorded activity for more than ${days} days as stale.`,
|
|
136
|
+
source: "GtmPolicy.staleDealDays",
|
|
137
|
+
confidence: "heuristic",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function closingSoonPolicyAssumption(windowDays: number, idleDays: number): PatchPlanAssumption {
|
|
142
|
+
return {
|
|
143
|
+
id: "closing-soon-idle-policy",
|
|
144
|
+
text: `Treated open deals closing within ${windowDays} days and idle for more than ${idleDays} days as silent slip risk.`,
|
|
145
|
+
source: "GtmPolicy.closingSoonDays/closingSoonIdleDays",
|
|
146
|
+
confidence: "heuristic",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assumptionsIfFindings(
|
|
151
|
+
findings: AuditFinding[],
|
|
152
|
+
assumptions: PatchPlanAssumption[],
|
|
153
|
+
): PatchPlanAssumption[] | undefined {
|
|
154
|
+
return findings.length > 0 ? assumptions : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
53
157
|
export function stableHash(value: string) {
|
|
54
158
|
let hash = 0;
|
|
55
159
|
for (let index = 0; index < value.length; index += 1) {
|
|
@@ -168,7 +272,11 @@ export const orphanAccountRule: GtmAuditRule = {
|
|
|
168
272
|
approvalRequired: true,
|
|
169
273
|
});
|
|
170
274
|
}
|
|
171
|
-
return {
|
|
275
|
+
return {
|
|
276
|
+
findings,
|
|
277
|
+
operations,
|
|
278
|
+
assumptions: assumptionsIfFindings(findings, [ORPHAN_ACCOUNT_RELATIONSHIP_ASSUMPTION]),
|
|
279
|
+
};
|
|
172
280
|
},
|
|
173
281
|
};
|
|
174
282
|
|
|
@@ -198,7 +306,11 @@ export const missingDealOwnerRule: GtmAuditRule = {
|
|
|
198
306
|
approvalRequired: true,
|
|
199
307
|
});
|
|
200
308
|
}
|
|
201
|
-
return {
|
|
309
|
+
return {
|
|
310
|
+
findings,
|
|
311
|
+
operations,
|
|
312
|
+
assumptions: assumptionsIfFindings(findings, [DEAL_OWNER_POLICY_ASSUMPTION]),
|
|
313
|
+
};
|
|
202
314
|
},
|
|
203
315
|
};
|
|
204
316
|
|
|
@@ -227,7 +339,11 @@ export const missingDealAccountRule: GtmAuditRule = {
|
|
|
227
339
|
approvalRequired: true,
|
|
228
340
|
});
|
|
229
341
|
}
|
|
230
|
-
return {
|
|
342
|
+
return {
|
|
343
|
+
findings,
|
|
344
|
+
operations,
|
|
345
|
+
assumptions: assumptionsIfFindings(findings, [DEAL_ACCOUNT_POLICY_ASSUMPTION]),
|
|
346
|
+
};
|
|
231
347
|
},
|
|
232
348
|
};
|
|
233
349
|
|
|
@@ -256,7 +372,14 @@ export const pastCloseDateRule: GtmAuditRule = {
|
|
|
256
372
|
approvalRequired: true,
|
|
257
373
|
});
|
|
258
374
|
}
|
|
259
|
-
return {
|
|
375
|
+
return {
|
|
376
|
+
findings,
|
|
377
|
+
operations,
|
|
378
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
379
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
380
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
381
|
+
]),
|
|
382
|
+
};
|
|
260
383
|
},
|
|
261
384
|
};
|
|
262
385
|
|
|
@@ -297,7 +420,15 @@ export const staleDealRule: GtmAuditRule = {
|
|
|
297
420
|
approvalRequired: true,
|
|
298
421
|
});
|
|
299
422
|
}
|
|
300
|
-
return {
|
|
423
|
+
return {
|
|
424
|
+
findings,
|
|
425
|
+
operations,
|
|
426
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
427
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
428
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
429
|
+
staleDealPolicyAssumption(policy.staleDealDays),
|
|
430
|
+
]),
|
|
431
|
+
};
|
|
301
432
|
},
|
|
302
433
|
};
|
|
303
434
|
|
|
@@ -327,7 +458,15 @@ export const missingDealAmountRule: GtmAuditRule = {
|
|
|
327
458
|
approvalRequired: true,
|
|
328
459
|
});
|
|
329
460
|
}
|
|
330
|
-
return {
|
|
461
|
+
return {
|
|
462
|
+
findings,
|
|
463
|
+
operations,
|
|
464
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
465
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
466
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
467
|
+
DEAL_AMOUNT_POLICY_ASSUMPTION,
|
|
468
|
+
]),
|
|
469
|
+
};
|
|
331
470
|
},
|
|
332
471
|
};
|
|
333
472
|
|
|
@@ -382,7 +521,11 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
|
|
|
382
521
|
"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.",
|
|
383
522
|
});
|
|
384
523
|
}
|
|
385
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
findings,
|
|
526
|
+
operations,
|
|
527
|
+
assumptions: assumptionsIfFindings(findings, [DUPLICATE_ACCOUNT_DOMAIN_ASSUMPTION]),
|
|
528
|
+
};
|
|
386
529
|
},
|
|
387
530
|
};
|
|
388
531
|
|
|
@@ -422,7 +565,11 @@ export const duplicateContactEmailRule: GtmAuditRule = {
|
|
|
422
565
|
"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.",
|
|
423
566
|
});
|
|
424
567
|
}
|
|
425
|
-
return {
|
|
568
|
+
return {
|
|
569
|
+
findings,
|
|
570
|
+
operations,
|
|
571
|
+
assumptions: assumptionsIfFindings(findings, [DUPLICATE_CONTACT_EMAIL_ASSUMPTION]),
|
|
572
|
+
};
|
|
426
573
|
},
|
|
427
574
|
};
|
|
428
575
|
|
|
@@ -473,7 +620,15 @@ export const duplicateOpenDealRule: GtmAuditRule = {
|
|
|
473
620
|
"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.",
|
|
474
621
|
});
|
|
475
622
|
}
|
|
476
|
-
return {
|
|
623
|
+
return {
|
|
624
|
+
findings,
|
|
625
|
+
operations,
|
|
626
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
627
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
628
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
629
|
+
DUPLICATE_OPEN_DEAL_ASSUMPTION,
|
|
630
|
+
]),
|
|
631
|
+
};
|
|
477
632
|
},
|
|
478
633
|
};
|
|
479
634
|
|
|
@@ -514,7 +669,15 @@ export const activeDealAccountWithoutContactsRule: GtmAuditRule = {
|
|
|
514
669
|
approvalRequired: true,
|
|
515
670
|
});
|
|
516
671
|
}
|
|
517
|
-
return {
|
|
672
|
+
return {
|
|
673
|
+
findings,
|
|
674
|
+
operations,
|
|
675
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
676
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
677
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
678
|
+
ACCOUNT_WITH_OPEN_DEAL_NEEDS_CONTACT_ASSUMPTION,
|
|
679
|
+
]),
|
|
680
|
+
};
|
|
518
681
|
},
|
|
519
682
|
};
|
|
520
683
|
|
|
@@ -559,13 +722,22 @@ export const closingSoonInactiveRule: GtmAuditRule = {
|
|
|
559
722
|
approvalRequired: true,
|
|
560
723
|
});
|
|
561
724
|
}
|
|
562
|
-
return {
|
|
725
|
+
return {
|
|
726
|
+
findings,
|
|
727
|
+
operations,
|
|
728
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
729
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
730
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
731
|
+
closingSoonPolicyAssumption(windowDays, idleDays),
|
|
732
|
+
]),
|
|
733
|
+
};
|
|
563
734
|
},
|
|
564
735
|
};
|
|
565
736
|
|
|
566
737
|
export const accountSingleSourceRule: GtmAuditRule = {
|
|
567
738
|
id: "account-single-source",
|
|
568
739
|
title: "Account exists in only one connected system",
|
|
740
|
+
emitsOperations: false,
|
|
569
741
|
description:
|
|
570
742
|
"On merged multi-system snapshots, flags accounts known to just one source — the seams where GTM systems disagree.",
|
|
571
743
|
category: "cross-system",
|
|
@@ -597,10 +769,18 @@ export const accountSingleSourceRule: GtmAuditRule = {
|
|
|
597
769
|
"Check whether the account is missing from the other systems or matched under a different domain/name.",
|
|
598
770
|
});
|
|
599
771
|
}
|
|
600
|
-
return {
|
|
772
|
+
return {
|
|
773
|
+
findings,
|
|
774
|
+
operations: [],
|
|
775
|
+
assumptions: assumptionsIfFindings(findings, [SINGLE_SOURCE_ACCOUNT_ASSUMPTION]),
|
|
776
|
+
};
|
|
601
777
|
},
|
|
602
778
|
};
|
|
603
779
|
|
|
780
|
+
export function isFindingsOnlyRule(rule: GtmAuditRule): boolean {
|
|
781
|
+
return rule.emitsOperations === false;
|
|
782
|
+
}
|
|
783
|
+
|
|
604
784
|
export const builtinAuditRules: GtmAuditRule[] = [
|
|
605
785
|
orphanAccountRule,
|
|
606
786
|
missingDealOwnerRule,
|