fullstackgtm 0.10.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 +381 -0
- package/INSTALL_FOR_AGENTS.md +87 -0
- package/LICENSE +202 -0
- package/README.md +230 -0
- package/dist/audit.d.ts +7 -0
- package/dist/audit.js +202 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +6 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +915 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +85 -0
- package/dist/connector.d.ts +30 -0
- package/dist/connector.js +94 -0
- package/dist/connectors/hubspot.d.ts +20 -0
- package/dist/connectors/hubspot.js +409 -0
- package/dist/connectors/hubspotAuth.d.ts +42 -0
- package/dist/connectors/hubspotAuth.js +189 -0
- package/dist/connectors/salesforce.d.ts +26 -0
- package/dist/connectors/salesforce.js +318 -0
- package/dist/connectors/salesforceAuth.d.ts +44 -0
- package/dist/connectors/salesforceAuth.js +120 -0
- package/dist/connectors/stripe.d.ts +27 -0
- package/dist/connectors/stripe.js +176 -0
- package/dist/credentials.d.ts +75 -0
- package/dist/credentials.js +197 -0
- package/dist/demo.d.ts +20 -0
- package/dist/demo.js +169 -0
- package/dist/diff.d.ts +46 -0
- package/dist/diff.js +107 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +109 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/mappings.d.ts +8 -0
- package/dist/mappings.js +123 -0
- package/dist/mcp-bin.d.ts +2 -0
- package/dist/mcp-bin.js +33 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +140 -0
- package/dist/merge.d.ts +48 -0
- package/dist/merge.js +145 -0
- package/dist/planStore.d.ts +31 -0
- package/dist/planStore.js +116 -0
- package/dist/rules.d.ts +24 -0
- package/dist/rules.js +512 -0
- package/dist/sampleData.d.ts +2 -0
- package/dist/sampleData.js +115 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +8 -0
- package/docs/api.md +72 -0
- package/docs/roadmap-to-1.0.md +121 -0
- package/llms.txt +25 -0
- package/package.json +76 -0
- package/src/audit.ts +242 -0
- package/src/bin.ts +7 -0
- package/src/cli.ts +1042 -0
- package/src/config.ts +113 -0
- package/src/connector.ts +140 -0
- package/src/connectors/hubspot.ts +528 -0
- package/src/connectors/hubspotAuth.ts +246 -0
- package/src/connectors/salesforce.ts +420 -0
- package/src/connectors/salesforceAuth.ts +167 -0
- package/src/connectors/stripe.ts +215 -0
- package/src/credentials.ts +282 -0
- package/src/demo.ts +200 -0
- package/src/diff.ts +158 -0
- package/src/format.ts +162 -0
- package/src/index.ts +129 -0
- package/src/mappings.ts +157 -0
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +185 -0
- package/src/merge.ts +235 -0
- package/src/planStore.ts +155 -0
- package/src/rules.ts +539 -0
- package/src/sampleData.ts +117 -0
- package/src/types.ts +372 -0
package/src/planStore.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
4
|
+
import type { ApprovalStatus, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Durable patch-plan workflow. A plan is an immutable proposal; the store
|
|
8
|
+
* tracks the mutable lifecycle around it — which operations a human approved,
|
|
9
|
+
* the concrete values they supplied, and every apply run. The hosted app's
|
|
10
|
+
* Convex tables and this file store are two implementations of the same
|
|
11
|
+
* contract.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type StoredPlan = {
|
|
15
|
+
plan: PatchPlan;
|
|
16
|
+
status: ApprovalStatus;
|
|
17
|
+
approvedOperationIds: string[];
|
|
18
|
+
valueOverrides: Record<string, unknown>;
|
|
19
|
+
runs: PatchPlanRun[];
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface PlanStore {
|
|
25
|
+
save(plan: PatchPlan): Promise<StoredPlan>;
|
|
26
|
+
get(planId: string): Promise<StoredPlan | null>;
|
|
27
|
+
list(status?: ApprovalStatus): Promise<StoredPlan[]>;
|
|
28
|
+
approveOperations(
|
|
29
|
+
planId: string,
|
|
30
|
+
operationIds: string[],
|
|
31
|
+
valueOverrides?: Record<string, unknown>,
|
|
32
|
+
): Promise<StoredPlan>;
|
|
33
|
+
reject(planId: string): Promise<StoredPlan>;
|
|
34
|
+
recordRun(planId: string, run: PatchPlanRun): Promise<StoredPlan>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
|
|
39
|
+
* per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
|
|
40
|
+
* file-based tooling composes with it.
|
|
41
|
+
*/
|
|
42
|
+
export function createFilePlanStore(directory?: string): PlanStore {
|
|
43
|
+
const dir = directory ?? join(credentialsDir(), "plans");
|
|
44
|
+
|
|
45
|
+
function pathFor(planId: string) {
|
|
46
|
+
if (!/^[\w.-]+$/.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
|
|
47
|
+
return join(dir, `${planId}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function read(planId: string): StoredPlan | null {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(pathFor(planId), "utf8")) as StoredPlan;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function write(stored: StoredPlan): StoredPlan {
|
|
59
|
+
// Plan files contain real CRM before/after values; keep them owner-only.
|
|
60
|
+
// When the store lives under the default home, lock that down too —
|
|
61
|
+
// otherwise an `audit --save` before any `login` would create the home
|
|
62
|
+
// directory world-readable.
|
|
63
|
+
if (!directory) ensureSecureHomeDir();
|
|
64
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
try {
|
|
66
|
+
chmodSync(dir, 0o700);
|
|
67
|
+
} catch {
|
|
68
|
+
// Non-POSIX filesystems ignore chmod.
|
|
69
|
+
}
|
|
70
|
+
const next = { ...stored, updatedAt: new Date().toISOString() };
|
|
71
|
+
writeSecureFile(pathFor(stored.plan.id), `${JSON.stringify(next, null, 2)}\n`);
|
|
72
|
+
return next;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mustRead(planId: string): StoredPlan {
|
|
76
|
+
const stored = read(planId);
|
|
77
|
+
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
78
|
+
return stored;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
async save(plan) {
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
return write({
|
|
85
|
+
plan,
|
|
86
|
+
status: plan.status,
|
|
87
|
+
approvedOperationIds: [],
|
|
88
|
+
valueOverrides: {},
|
|
89
|
+
runs: [],
|
|
90
|
+
createdAt: now,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async get(planId) {
|
|
96
|
+
return read(planId);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async list(status) {
|
|
100
|
+
let entries: string[] = [];
|
|
101
|
+
try {
|
|
102
|
+
entries = readdirSync(dir);
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const plans = entries
|
|
107
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
108
|
+
.map((entry) => read(entry.slice(0, -".json".length)))
|
|
109
|
+
.filter((stored): stored is StoredPlan => stored !== null)
|
|
110
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
111
|
+
return status ? plans.filter((stored) => stored.status === status) : plans;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async approveOperations(planId, operationIds, valueOverrides = {}) {
|
|
115
|
+
const stored = mustRead(planId);
|
|
116
|
+
if (stored.status === "applied") {
|
|
117
|
+
throw new Error(`Plan ${planId} has already been applied.`);
|
|
118
|
+
}
|
|
119
|
+
if (stored.status === "rejected") {
|
|
120
|
+
throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
|
|
121
|
+
}
|
|
122
|
+
const known = new Set(stored.plan.operations.map((operation) => operation.id));
|
|
123
|
+
for (const operationId of operationIds) {
|
|
124
|
+
if (!known.has(operationId)) {
|
|
125
|
+
throw new Error(`Plan ${planId} has no operation ${operationId}.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return write({
|
|
129
|
+
...stored,
|
|
130
|
+
status: "approved",
|
|
131
|
+
approvedOperationIds: Array.from(
|
|
132
|
+
new Set([...stored.approvedOperationIds, ...operationIds]),
|
|
133
|
+
),
|
|
134
|
+
valueOverrides: { ...stored.valueOverrides, ...valueOverrides },
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async reject(planId) {
|
|
139
|
+
const stored = mustRead(planId);
|
|
140
|
+
if (stored.status === "applied") {
|
|
141
|
+
throw new Error(`Plan ${planId} has already been applied.`);
|
|
142
|
+
}
|
|
143
|
+
return write({ ...stored, status: "rejected", approvedOperationIds: [] });
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async recordRun(planId, run) {
|
|
147
|
+
const stored = mustRead(planId);
|
|
148
|
+
return write({
|
|
149
|
+
...stored,
|
|
150
|
+
status: run.status === "applied" || run.status === "partial" ? "applied" : stored.status,
|
|
151
|
+
runs: [...stored.runs, run],
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuditFinding,
|
|
3
|
+
CanonicalActivity,
|
|
4
|
+
CanonicalContact,
|
|
5
|
+
CanonicalDeal,
|
|
6
|
+
CanonicalGtmSnapshot,
|
|
7
|
+
GtmAuditRule,
|
|
8
|
+
GtmSnapshotIndex,
|
|
9
|
+
RiskLevel,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Placeholder used as `afterValue` when the right value is a human decision
|
|
14
|
+
* (e.g. which owner to assign). Apply orchestration refuses to write these
|
|
15
|
+
* unless an explicit override value is supplied at approval time.
|
|
16
|
+
*/
|
|
17
|
+
export const REQUIRES_HUMAN_PREFIX = "requires_human_";
|
|
18
|
+
|
|
19
|
+
export function requiresHumanInput(value: unknown): boolean {
|
|
20
|
+
return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function auditFindingId(ruleId: string, objectId: string) {
|
|
24
|
+
return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function patchOperationId(ruleId: string, objectId: string) {
|
|
28
|
+
return `op_${stableHash(`${ruleId}:${objectId}`)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function stableHash(value: string) {
|
|
32
|
+
let hash = 0;
|
|
33
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
34
|
+
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return hash.toString(36);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildSnapshotIndex(snapshot: CanonicalGtmSnapshot): GtmSnapshotIndex {
|
|
40
|
+
const contactsByAccountId = new Map<string, CanonicalContact[]>();
|
|
41
|
+
const dealsByAccountId = new Map<string, CanonicalDeal[]>();
|
|
42
|
+
const activitiesByDealId = new Map<string, CanonicalActivity[]>();
|
|
43
|
+
|
|
44
|
+
for (const contact of snapshot.contacts) {
|
|
45
|
+
if (!contact.accountId) continue;
|
|
46
|
+
const existing = contactsByAccountId.get(contact.accountId) ?? [];
|
|
47
|
+
existing.push(contact);
|
|
48
|
+
contactsByAccountId.set(contact.accountId, existing);
|
|
49
|
+
}
|
|
50
|
+
for (const deal of snapshot.deals) {
|
|
51
|
+
if (!deal.accountId) continue;
|
|
52
|
+
const existing = dealsByAccountId.get(deal.accountId) ?? [];
|
|
53
|
+
existing.push(deal);
|
|
54
|
+
dealsByAccountId.set(deal.accountId, existing);
|
|
55
|
+
}
|
|
56
|
+
for (const activity of snapshot.activities) {
|
|
57
|
+
if (!activity.dealId) continue;
|
|
58
|
+
const existing = activitiesByDealId.get(activity.dealId) ?? [];
|
|
59
|
+
existing.push(activity);
|
|
60
|
+
activitiesByDealId.set(activity.dealId, existing);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
usersById: new Map(snapshot.users.map((user) => [user.id, user])),
|
|
65
|
+
accountsById: new Map(snapshot.accounts.map((account) => [account.id, account])),
|
|
66
|
+
contactsByAccountId,
|
|
67
|
+
dealsByAccountId,
|
|
68
|
+
activitiesByDealId,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isOpen(deal: CanonicalDeal) {
|
|
73
|
+
return deal.isClosed !== true && deal.isWon !== true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dealFinding(deal: CanonicalDeal, ruleId: string, title: string): AuditFinding {
|
|
77
|
+
return {
|
|
78
|
+
id: auditFindingId(ruleId, deal.id),
|
|
79
|
+
objectType: "deal",
|
|
80
|
+
objectId: deal.id,
|
|
81
|
+
ruleId,
|
|
82
|
+
title,
|
|
83
|
+
severity: "warning",
|
|
84
|
+
summary: `${deal.name} violates ${ruleId.replace(/-/g, " ")} policy.`,
|
|
85
|
+
recommendation: "Review the proposed patch operation and approve an explicit CRM update.",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function staleDays(deal: CanonicalDeal, today: string) {
|
|
90
|
+
const reference = deal.lastActivityAt ?? deal.lastSyncAt ?? deal.closeDate;
|
|
91
|
+
if (!reference) return 0;
|
|
92
|
+
return Math.max(0, daysBetween(reference, today));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function daysBetween(start: string, end: string) {
|
|
96
|
+
const startMs = Date.parse(start);
|
|
97
|
+
const endMs = Date.parse(end);
|
|
98
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
|
|
99
|
+
return Math.floor((endMs - startMs) / 86_400_000);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function compareDate(left: string, right: string) {
|
|
103
|
+
return Date.parse(left) - Date.parse(right);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function riskForStaleness(days: number): RiskLevel {
|
|
107
|
+
if (days >= 90) return "high";
|
|
108
|
+
if (days >= 45) return "medium";
|
|
109
|
+
return "low";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const orphanAccountRule: GtmAuditRule = {
|
|
113
|
+
id: "orphan-account",
|
|
114
|
+
title: "Account has no contacts or deals",
|
|
115
|
+
description:
|
|
116
|
+
"Flags accounts not connected to any contact or opportunity so someone owns the next action.",
|
|
117
|
+
category: "hygiene",
|
|
118
|
+
evaluate: ({ snapshot, index }) => {
|
|
119
|
+
const findings: AuditFinding[] = [];
|
|
120
|
+
const operations = [];
|
|
121
|
+
for (const account of snapshot.accounts) {
|
|
122
|
+
const contacts = index.contactsByAccountId.get(account.id) ?? [];
|
|
123
|
+
const deals = index.dealsByAccountId.get(account.id) ?? [];
|
|
124
|
+
if (contacts.length > 0 || deals.length > 0) continue;
|
|
125
|
+
findings.push({
|
|
126
|
+
id: auditFindingId("orphan-account", account.id),
|
|
127
|
+
objectType: "account",
|
|
128
|
+
objectId: account.id,
|
|
129
|
+
ruleId: "orphan-account",
|
|
130
|
+
title: "Account has no contacts or deals",
|
|
131
|
+
severity: "warning",
|
|
132
|
+
summary: `${account.name} is not connected to any contact or open opportunity.`,
|
|
133
|
+
recommendation:
|
|
134
|
+
"Review whether the account should be enriched, assigned for research, or archived.",
|
|
135
|
+
});
|
|
136
|
+
operations.push({
|
|
137
|
+
id: patchOperationId("orphan-account", account.id),
|
|
138
|
+
objectType: "account" as const,
|
|
139
|
+
objectId: account.id,
|
|
140
|
+
operation: "create_task" as const,
|
|
141
|
+
field: "follow_up_task",
|
|
142
|
+
beforeValue: null,
|
|
143
|
+
afterValue: "Research account fit and decide whether to archive or enrich",
|
|
144
|
+
reason: "Orphan accounts add CRM noise unless someone owns the next action.",
|
|
145
|
+
riskLevel: "low" as const,
|
|
146
|
+
approvalRequired: true,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return { findings, operations };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const missingDealOwnerRule: GtmAuditRule = {
|
|
154
|
+
id: "missing-deal-owner",
|
|
155
|
+
title: "Deal has no valid owner",
|
|
156
|
+
description: "Flags deals whose owner is missing or not a known user.",
|
|
157
|
+
category: "hygiene",
|
|
158
|
+
evaluate: ({ snapshot, policy, index }) => {
|
|
159
|
+
if (!policy.requireDealOwner) return { findings: [], operations: [] };
|
|
160
|
+
const findings: AuditFinding[] = [];
|
|
161
|
+
const operations = [];
|
|
162
|
+
for (const deal of snapshot.deals) {
|
|
163
|
+
if (deal.ownerId && index.usersById.has(deal.ownerId)) continue;
|
|
164
|
+
findings.push(dealFinding(deal, "missing-deal-owner", "Deal has no valid owner"));
|
|
165
|
+
operations.push({
|
|
166
|
+
id: patchOperationId("missing-deal-owner", deal.id),
|
|
167
|
+
objectType: "deal" as const,
|
|
168
|
+
objectId: deal.id,
|
|
169
|
+
operation: "set_field" as const,
|
|
170
|
+
field: "ownerId",
|
|
171
|
+
beforeValue: deal.ownerId ?? null,
|
|
172
|
+
afterValue: "requires_human_owner_selection",
|
|
173
|
+
reason:
|
|
174
|
+
"Every open opportunity needs a human owner before the agent can suggest routing.",
|
|
175
|
+
riskLevel: "medium" as const,
|
|
176
|
+
approvalRequired: true,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return { findings, operations };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const missingDealAccountRule: GtmAuditRule = {
|
|
184
|
+
id: "missing-deal-account",
|
|
185
|
+
title: "Deal is not linked to an account",
|
|
186
|
+
description: "Flags deals that are not associated with a known account.",
|
|
187
|
+
category: "hygiene",
|
|
188
|
+
evaluate: ({ snapshot, policy, index }) => {
|
|
189
|
+
if (!policy.requireAccountForDeal) return { findings: [], operations: [] };
|
|
190
|
+
const findings: AuditFinding[] = [];
|
|
191
|
+
const operations = [];
|
|
192
|
+
for (const deal of snapshot.deals) {
|
|
193
|
+
if (deal.accountId && index.accountsById.has(deal.accountId)) continue;
|
|
194
|
+
findings.push(dealFinding(deal, "missing-deal-account", "Deal is not linked to an account"));
|
|
195
|
+
operations.push({
|
|
196
|
+
id: patchOperationId("missing-deal-account", deal.id),
|
|
197
|
+
objectType: "deal" as const,
|
|
198
|
+
objectId: deal.id,
|
|
199
|
+
operation: "link_record" as const,
|
|
200
|
+
field: "accountId",
|
|
201
|
+
beforeValue: deal.accountId ?? null,
|
|
202
|
+
afterValue: "requires_human_account_selection",
|
|
203
|
+
reason: "Opportunity reporting, ABM, attribution, and forecasting need account context.",
|
|
204
|
+
riskLevel: "medium" as const,
|
|
205
|
+
approvalRequired: true,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return { findings, operations };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const pastCloseDateRule: GtmAuditRule = {
|
|
213
|
+
id: "past-close-date",
|
|
214
|
+
title: "Open deal has a past close date",
|
|
215
|
+
description: "Flags open deals whose close date is already behind today.",
|
|
216
|
+
category: "hygiene",
|
|
217
|
+
evaluate: ({ snapshot, policy }) => {
|
|
218
|
+
const findings: AuditFinding[] = [];
|
|
219
|
+
const operations = [];
|
|
220
|
+
for (const deal of snapshot.deals) {
|
|
221
|
+
if (!isOpen(deal) || !deal.closeDate) continue;
|
|
222
|
+
if (compareDate(deal.closeDate, policy.today) >= 0) continue;
|
|
223
|
+
findings.push(dealFinding(deal, "past-close-date", "Open deal has a past close date"));
|
|
224
|
+
operations.push({
|
|
225
|
+
id: patchOperationId("past-close-date", deal.id),
|
|
226
|
+
objectType: "deal" as const,
|
|
227
|
+
objectId: deal.id,
|
|
228
|
+
operation: "set_field" as const,
|
|
229
|
+
field: "closeDate",
|
|
230
|
+
beforeValue: deal.closeDate,
|
|
231
|
+
afterValue: "requires_human_close_date_selection",
|
|
232
|
+
reason: "Past close dates make forecast and pipeline aging unreliable.",
|
|
233
|
+
riskLevel: "medium" as const,
|
|
234
|
+
approvalRequired: true,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { findings, operations };
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export const staleDealRule: GtmAuditRule = {
|
|
242
|
+
id: "stale-deal",
|
|
243
|
+
title: "Deal has stale activity",
|
|
244
|
+
description:
|
|
245
|
+
"Flags open deals with no recorded activity for longer than the policy's stale window.",
|
|
246
|
+
category: "hygiene",
|
|
247
|
+
evaluate: ({ snapshot, policy }) => {
|
|
248
|
+
const findings: AuditFinding[] = [];
|
|
249
|
+
const operations = [];
|
|
250
|
+
for (const deal of snapshot.deals) {
|
|
251
|
+
if (!isOpen(deal)) continue;
|
|
252
|
+
const days = staleDays(deal, policy.today);
|
|
253
|
+
if (days <= policy.staleDealDays) continue;
|
|
254
|
+
findings.push({
|
|
255
|
+
id: auditFindingId("stale-deal", deal.id),
|
|
256
|
+
objectType: "deal",
|
|
257
|
+
objectId: deal.id,
|
|
258
|
+
ruleId: "stale-deal",
|
|
259
|
+
title: "Deal has stale activity",
|
|
260
|
+
severity: "warning",
|
|
261
|
+
summary: `${deal.name} has had no recorded activity for ${days} days.`,
|
|
262
|
+
recommendation:
|
|
263
|
+
"Ask the owner to confirm next step, close lost, or update the forecast category.",
|
|
264
|
+
});
|
|
265
|
+
operations.push({
|
|
266
|
+
id: patchOperationId("stale-deal", deal.id),
|
|
267
|
+
objectType: "deal" as const,
|
|
268
|
+
objectId: deal.id,
|
|
269
|
+
operation: "create_task" as const,
|
|
270
|
+
field: "next_step_task",
|
|
271
|
+
beforeValue: null,
|
|
272
|
+
afterValue: "Confirm next step or close out stale opportunity",
|
|
273
|
+
reason: "Stale open pipeline inflates forecast coverage and hides execution risk.",
|
|
274
|
+
riskLevel: riskForStaleness(days),
|
|
275
|
+
approvalRequired: true,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return { findings, operations };
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export const missingDealAmountRule: GtmAuditRule = {
|
|
283
|
+
id: "missing-deal-amount",
|
|
284
|
+
title: "Open deal has no amount",
|
|
285
|
+
description: "Flags open deals without an amount; they make forecast coverage meaningless.",
|
|
286
|
+
category: "forecast",
|
|
287
|
+
evaluate: ({ snapshot, policy }) => {
|
|
288
|
+
if (policy.requireDealAmount === false) return { findings: [], operations: [] };
|
|
289
|
+
const findings: AuditFinding[] = [];
|
|
290
|
+
const operations = [];
|
|
291
|
+
for (const deal of snapshot.deals) {
|
|
292
|
+
if (!isOpen(deal)) continue;
|
|
293
|
+
if (deal.amount !== undefined && deal.amount !== 0) continue;
|
|
294
|
+
findings.push(dealFinding(deal, "missing-deal-amount", "Open deal has no amount"));
|
|
295
|
+
operations.push({
|
|
296
|
+
id: patchOperationId("missing-deal-amount", deal.id),
|
|
297
|
+
objectType: "deal" as const,
|
|
298
|
+
objectId: deal.id,
|
|
299
|
+
operation: "set_field" as const,
|
|
300
|
+
field: "amount",
|
|
301
|
+
beforeValue: deal.amount ?? null,
|
|
302
|
+
afterValue: "requires_human_amount_selection",
|
|
303
|
+
reason: "Amountless open pipeline understates coverage and breaks weighted forecasts.",
|
|
304
|
+
riskLevel: "medium" as const,
|
|
305
|
+
approvalRequired: true,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return { findings, operations };
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function duplicateGroups<T>(items: T[], keyOf: (item: T) => string | undefined): Map<string, T[]> {
|
|
313
|
+
const groups = new Map<string, T[]>();
|
|
314
|
+
for (const item of items) {
|
|
315
|
+
const key = keyOf(item)?.trim().toLowerCase();
|
|
316
|
+
if (!key) continue;
|
|
317
|
+
const existing = groups.get(key) ?? [];
|
|
318
|
+
existing.push(item);
|
|
319
|
+
groups.set(key, existing);
|
|
320
|
+
}
|
|
321
|
+
for (const [key, members] of Array.from(groups.entries())) {
|
|
322
|
+
if (members.length < 2) groups.delete(key);
|
|
323
|
+
}
|
|
324
|
+
return groups;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export const duplicateAccountDomainRule: GtmAuditRule = {
|
|
328
|
+
id: "duplicate-account-domain",
|
|
329
|
+
title: "Accounts share the same domain",
|
|
330
|
+
description:
|
|
331
|
+
"Flags accounts with identical domains — usually duplicates splitting activity, deals, and attribution.",
|
|
332
|
+
category: "data-quality",
|
|
333
|
+
evaluate: ({ snapshot }) => {
|
|
334
|
+
const findings: AuditFinding[] = [];
|
|
335
|
+
const operations = [];
|
|
336
|
+
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
|
|
337
|
+
const anchor = accounts[0];
|
|
338
|
+
findings.push({
|
|
339
|
+
id: auditFindingId("duplicate-account-domain", anchor.id),
|
|
340
|
+
objectType: "account",
|
|
341
|
+
objectId: anchor.id,
|
|
342
|
+
ruleId: "duplicate-account-domain",
|
|
343
|
+
title: "Accounts share the same domain",
|
|
344
|
+
severity: "warning",
|
|
345
|
+
summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.`,
|
|
346
|
+
recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
|
|
347
|
+
});
|
|
348
|
+
operations.push({
|
|
349
|
+
id: patchOperationId("duplicate-account-domain", anchor.id),
|
|
350
|
+
objectType: "account" as const,
|
|
351
|
+
objectId: anchor.id,
|
|
352
|
+
operation: "create_task" as const,
|
|
353
|
+
field: "merge_review_task",
|
|
354
|
+
beforeValue: null,
|
|
355
|
+
afterValue: `Review ${accounts.length} accounts sharing ${domain} and merge duplicates`,
|
|
356
|
+
reason: "Duplicate accounts split pipeline, attribution, and ownership.",
|
|
357
|
+
riskLevel: "medium" as const,
|
|
358
|
+
approvalRequired: true,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return { findings, operations };
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export const duplicateContactEmailRule: GtmAuditRule = {
|
|
366
|
+
id: "duplicate-contact-email",
|
|
367
|
+
title: "Contacts share the same email",
|
|
368
|
+
description:
|
|
369
|
+
"Flags contacts with identical emails — duplicates that fragment engagement history and routing.",
|
|
370
|
+
category: "data-quality",
|
|
371
|
+
evaluate: ({ snapshot }) => {
|
|
372
|
+
const findings: AuditFinding[] = [];
|
|
373
|
+
const operations = [];
|
|
374
|
+
for (const [email, contacts] of duplicateGroups(snapshot.contacts, (contact) => contact.email)) {
|
|
375
|
+
const anchor = contacts[0];
|
|
376
|
+
findings.push({
|
|
377
|
+
id: auditFindingId("duplicate-contact-email", anchor.id),
|
|
378
|
+
objectType: "contact",
|
|
379
|
+
objectId: anchor.id,
|
|
380
|
+
ruleId: "duplicate-contact-email",
|
|
381
|
+
title: "Contacts share the same email",
|
|
382
|
+
severity: "warning",
|
|
383
|
+
summary: `${contacts.length} contacts share ${email}.`,
|
|
384
|
+
recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
|
|
385
|
+
});
|
|
386
|
+
operations.push({
|
|
387
|
+
id: patchOperationId("duplicate-contact-email", anchor.id),
|
|
388
|
+
objectType: "contact" as const,
|
|
389
|
+
objectId: anchor.id,
|
|
390
|
+
operation: "create_task" as const,
|
|
391
|
+
field: "merge_review_task",
|
|
392
|
+
beforeValue: null,
|
|
393
|
+
afterValue: `Review ${contacts.length} contacts sharing ${email} and merge duplicates`,
|
|
394
|
+
reason: "Duplicate contacts fragment engagement history and double-route outreach.",
|
|
395
|
+
riskLevel: "low" as const,
|
|
396
|
+
approvalRequired: true,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return { findings, operations };
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export const activeDealAccountWithoutContactsRule: GtmAuditRule = {
|
|
404
|
+
id: "active-deal-account-without-contacts",
|
|
405
|
+
title: "Account with open pipeline has no contacts",
|
|
406
|
+
description:
|
|
407
|
+
"Flags accounts carrying open deals without a single contact — pipeline with nobody to talk to.",
|
|
408
|
+
category: "coverage",
|
|
409
|
+
evaluate: ({ index }) => {
|
|
410
|
+
const findings: AuditFinding[] = [];
|
|
411
|
+
const operations = [];
|
|
412
|
+
for (const [accountId, deals] of index.dealsByAccountId) {
|
|
413
|
+
if (!deals.some(isOpen)) continue;
|
|
414
|
+
if ((index.contactsByAccountId.get(accountId) ?? []).length > 0) continue;
|
|
415
|
+
const account = index.accountsById.get(accountId);
|
|
416
|
+
if (!account) continue;
|
|
417
|
+
findings.push({
|
|
418
|
+
id: auditFindingId("active-deal-account-without-contacts", account.id),
|
|
419
|
+
objectType: "account",
|
|
420
|
+
objectId: account.id,
|
|
421
|
+
ruleId: "active-deal-account-without-contacts",
|
|
422
|
+
title: "Account with open pipeline has no contacts",
|
|
423
|
+
severity: "warning",
|
|
424
|
+
summary: `${account.name} has open deals but no contacts on record.`,
|
|
425
|
+
recommendation: "Add the champion and buying-committee contacts before the deal advances.",
|
|
426
|
+
});
|
|
427
|
+
operations.push({
|
|
428
|
+
id: patchOperationId("active-deal-account-without-contacts", account.id),
|
|
429
|
+
objectType: "account" as const,
|
|
430
|
+
objectId: account.id,
|
|
431
|
+
operation: "create_task" as const,
|
|
432
|
+
field: "add_contacts_task",
|
|
433
|
+
beforeValue: null,
|
|
434
|
+
afterValue: "Add champion and buying-committee contacts for the open opportunities",
|
|
435
|
+
reason: "Open pipeline without contacts cannot be advanced, routed, or marketed to.",
|
|
436
|
+
riskLevel: "low" as const,
|
|
437
|
+
approvalRequired: true,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return { findings, operations };
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
export const closingSoonInactiveRule: GtmAuditRule = {
|
|
445
|
+
id: "closing-soon-inactive",
|
|
446
|
+
title: "Deal closing soon with no recent activity",
|
|
447
|
+
description:
|
|
448
|
+
"Flags open deals inside the closing-soon window whose last activity is older than the idle threshold.",
|
|
449
|
+
category: "forecast",
|
|
450
|
+
evaluate: ({ snapshot, policy }) => {
|
|
451
|
+
const windowDays = policy.closingSoonDays ?? 14;
|
|
452
|
+
const idleDays = policy.closingSoonIdleDays ?? 7;
|
|
453
|
+
const findings: AuditFinding[] = [];
|
|
454
|
+
const operations = [];
|
|
455
|
+
for (const deal of snapshot.deals) {
|
|
456
|
+
if (!isOpen(deal) || !deal.closeDate) continue;
|
|
457
|
+
const daysToClose = daysBetween(policy.today, deal.closeDate);
|
|
458
|
+
if (daysToClose < 0 || daysToClose > windowDays) continue;
|
|
459
|
+
const idle = staleDays(deal, policy.today);
|
|
460
|
+
if (idle <= idleDays) continue;
|
|
461
|
+
findings.push({
|
|
462
|
+
id: auditFindingId("closing-soon-inactive", deal.id),
|
|
463
|
+
objectType: "deal",
|
|
464
|
+
objectId: deal.id,
|
|
465
|
+
ruleId: "closing-soon-inactive",
|
|
466
|
+
title: "Deal closing soon with no recent activity",
|
|
467
|
+
severity: "critical",
|
|
468
|
+
summary: `${deal.name} closes in ${daysToClose} days but has had no activity for ${idle} days.`,
|
|
469
|
+
recommendation:
|
|
470
|
+
"Confirm the close plan with the owner today, or move the close date and forecast category.",
|
|
471
|
+
});
|
|
472
|
+
operations.push({
|
|
473
|
+
id: patchOperationId("closing-soon-inactive", deal.id),
|
|
474
|
+
objectType: "deal" as const,
|
|
475
|
+
objectId: deal.id,
|
|
476
|
+
operation: "create_task" as const,
|
|
477
|
+
field: "close_plan_task",
|
|
478
|
+
beforeValue: null,
|
|
479
|
+
afterValue: "Confirm close plan or update close date and forecast category",
|
|
480
|
+
reason: "Deals forecast to close imminently without engagement are silent slip risk.",
|
|
481
|
+
riskLevel: "high" as const,
|
|
482
|
+
approvalRequired: true,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return { findings, operations };
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
export const accountSingleSourceRule: GtmAuditRule = {
|
|
490
|
+
id: "account-single-source",
|
|
491
|
+
title: "Account exists in only one connected system",
|
|
492
|
+
description:
|
|
493
|
+
"On merged multi-system snapshots, flags accounts known to just one source — the seams where GTM systems disagree.",
|
|
494
|
+
category: "cross-system",
|
|
495
|
+
evaluate: ({ snapshot }) => {
|
|
496
|
+
const providersSeen = new Set(
|
|
497
|
+
snapshot.accounts.flatMap((account) =>
|
|
498
|
+
(account.identities ?? []).map((identity) => String(identity.provider)),
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
// Only meaningful when the snapshot actually spans systems.
|
|
502
|
+
if (providersSeen.size < 2) return { findings: [], operations: [] };
|
|
503
|
+
|
|
504
|
+
const findings: AuditFinding[] = [];
|
|
505
|
+
for (const account of snapshot.accounts) {
|
|
506
|
+
const sources = new Set(
|
|
507
|
+
(account.identities ?? []).map((identity) => String(identity.provider)),
|
|
508
|
+
);
|
|
509
|
+
if (sources.size !== 1) continue;
|
|
510
|
+
const source = Array.from(sources)[0];
|
|
511
|
+
findings.push({
|
|
512
|
+
id: auditFindingId("account-single-source", account.id),
|
|
513
|
+
objectType: "account",
|
|
514
|
+
objectId: account.id,
|
|
515
|
+
ruleId: "account-single-source",
|
|
516
|
+
title: "Account exists in only one connected system",
|
|
517
|
+
severity: "info",
|
|
518
|
+
summary: `${account.name} exists only in ${source} (${providersSeen.size} systems connected).`,
|
|
519
|
+
recommendation:
|
|
520
|
+
"Check whether the account is missing from the other systems or matched under a different domain/name.",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return { findings, operations: [] };
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
export const builtinAuditRules: GtmAuditRule[] = [
|
|
528
|
+
orphanAccountRule,
|
|
529
|
+
missingDealOwnerRule,
|
|
530
|
+
missingDealAccountRule,
|
|
531
|
+
pastCloseDateRule,
|
|
532
|
+
staleDealRule,
|
|
533
|
+
missingDealAmountRule,
|
|
534
|
+
duplicateAccountDomainRule,
|
|
535
|
+
duplicateContactEmailRule,
|
|
536
|
+
activeDealAccountWithoutContactsRule,
|
|
537
|
+
closingSoonInactiveRule,
|
|
538
|
+
accountSingleSourceRule,
|
|
539
|
+
];
|