fullstackgtm 0.10.0 → 0.11.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/src/report.ts ADDED
@@ -0,0 +1,502 @@
1
+ import { REQUIRES_HUMAN_PREFIX } from "./rules.ts";
2
+ import type {
3
+ AuditFinding,
4
+ AuditFindingSeverity,
5
+ CanonicalGtmSnapshot,
6
+ GtmAuditRule,
7
+ PatchPlan,
8
+ } from "./types.ts";
9
+
10
+ /**
11
+ * Client-ready rendering of an audit patch plan: the same findings the CLI
12
+ * prints for operators, reshaped for the person who owns the CRM — counts up
13
+ * front, prose summary, per-rule detail with capped examples, and next steps.
14
+ * Deterministic: identical plan + options produce identical output, so a
15
+ * report can be regenerated and diffed across engagements.
16
+ */
17
+
18
+ export type ReportOptions = {
19
+ /** Report heading (default "GTM Data Health Report"). */
20
+ title?: string;
21
+ /** Organization the report is about; shown in the heading and summary. */
22
+ clientName?: string;
23
+ /** Attribution line in the footer (e.g. a consultancy or team name). */
24
+ preparedBy?: string;
25
+ /** Report date (YYYY-MM-DD); defaults to the plan's creation date (UTC). */
26
+ date?: string;
27
+ /** Example records listed per rule before truncating (default 10). */
28
+ maxExamplesPerRule?: number;
29
+ /** Rule metadata used for section titles, descriptions, and categories. */
30
+ rules?: GtmAuditRule[];
31
+ /** Snapshot the plan was generated from; enables record counts and rates. */
32
+ snapshot?: CanonicalGtmSnapshot;
33
+ };
34
+
35
+ const SEVERITY_ORDER: AuditFindingSeverity[] = ["critical", "warning", "info"];
36
+ const SEVERITY_RANK: Record<AuditFindingSeverity, number> = {
37
+ critical: 2,
38
+ warning: 1,
39
+ info: 0,
40
+ };
41
+
42
+ type RuleSection = {
43
+ ruleId: string;
44
+ title: string;
45
+ description?: string;
46
+ category: string;
47
+ severity: AuditFindingSeverity;
48
+ findings: AuditFinding[];
49
+ };
50
+
51
+ type ReportModel = {
52
+ title: string;
53
+ clientName?: string;
54
+ preparedBy?: string;
55
+ date: string;
56
+ provider?: string;
57
+ recordCounts?: { label: string; count: number }[];
58
+ totalRecords?: number;
59
+ severityCounts: Record<AuditFindingSeverity, number>;
60
+ affectedRecords: number;
61
+ sections: RuleSection[];
62
+ maxExamplesPerRule: number;
63
+ operationCount: number;
64
+ humanInputOperationCount: number;
65
+ recordLabels: Map<string, string>;
66
+ summaryText: string;
67
+ };
68
+
69
+ export function buildReportModel(plan: PatchPlan, options: ReportOptions = {}): ReportModel {
70
+ const ruleMeta = new Map((options.rules ?? []).map((rule) => [rule.id, rule]));
71
+ const byRule = new Map<string, AuditFinding[]>();
72
+ for (const finding of plan.findings) {
73
+ const findings = byRule.get(finding.ruleId) ?? [];
74
+ findings.push(finding);
75
+ byRule.set(finding.ruleId, findings);
76
+ }
77
+
78
+ const sections: RuleSection[] = Array.from(byRule.entries())
79
+ .map(([ruleId, findings]) => {
80
+ const meta = ruleMeta.get(ruleId);
81
+ const severity = findings.reduce<AuditFindingSeverity>(
82
+ (worst, finding) =>
83
+ SEVERITY_RANK[finding.severity] > SEVERITY_RANK[worst] ? finding.severity : worst,
84
+ "info",
85
+ );
86
+ return {
87
+ ruleId,
88
+ title: meta?.title ?? ruleId,
89
+ description: meta?.description,
90
+ category: meta?.category ?? "uncategorized",
91
+ severity,
92
+ findings,
93
+ };
94
+ })
95
+ .sort(
96
+ (a, b) =>
97
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
98
+ b.findings.length - a.findings.length ||
99
+ a.ruleId.localeCompare(b.ruleId),
100
+ );
101
+
102
+ const severityCounts: Record<AuditFindingSeverity, number> = {
103
+ critical: 0,
104
+ warning: 0,
105
+ info: 0,
106
+ };
107
+ const affected = new Set<string>();
108
+ for (const finding of plan.findings) {
109
+ severityCounts[finding.severity] += 1;
110
+ affected.add(`${finding.objectType}:${finding.objectId}`);
111
+ }
112
+
113
+ const snapshot = options.snapshot;
114
+ const recordCounts = snapshot
115
+ ? [
116
+ { label: "Accounts", count: snapshot.accounts.length },
117
+ { label: "Contacts", count: snapshot.contacts.length },
118
+ { label: "Deals", count: snapshot.deals.length },
119
+ { label: "Users", count: snapshot.users.length },
120
+ { label: "Activities", count: snapshot.activities.length },
121
+ ]
122
+ : undefined;
123
+ const totalRecords = recordCounts?.reduce((sum, entry) => sum + entry.count, 0);
124
+
125
+ const recordLabels = new Map<string, string>();
126
+ if (snapshot) {
127
+ for (const row of snapshot.accounts) recordLabels.set(`account:${row.id}`, row.name);
128
+ for (const row of snapshot.users) recordLabels.set(`user:${row.id}`, row.name);
129
+ for (const row of snapshot.deals) recordLabels.set(`deal:${row.id}`, row.name);
130
+ for (const row of snapshot.contacts) {
131
+ const name = [row.firstName, row.lastName].filter(Boolean).join(" ") || row.email;
132
+ if (name) recordLabels.set(`contact:${row.id}`, name);
133
+ }
134
+ }
135
+
136
+ const humanInputOperationCount = plan.operations.filter(
137
+ (operation) =>
138
+ typeof operation.afterValue === "string" &&
139
+ operation.afterValue.startsWith(REQUIRES_HUMAN_PREFIX),
140
+ ).length;
141
+
142
+ const model: ReportModel = {
143
+ title: options.title ?? "GTM Data Health Report",
144
+ clientName: options.clientName,
145
+ preparedBy: options.preparedBy,
146
+ date: options.date ?? plan.createdAt.slice(0, 10),
147
+ provider: snapshot?.provider,
148
+ recordCounts,
149
+ totalRecords,
150
+ severityCounts,
151
+ affectedRecords: affected.size,
152
+ sections,
153
+ maxExamplesPerRule: Math.max(1, options.maxExamplesPerRule ?? 10),
154
+ operationCount: plan.operations.length,
155
+ humanInputOperationCount,
156
+ recordLabels,
157
+ summaryText: "",
158
+ };
159
+ model.summaryText = summaryText(model);
160
+ return model;
161
+ }
162
+
163
+ function summaryText(model: ReportModel): string {
164
+ const subject = model.clientName ? `${model.clientName}'s CRM data` : "the CRM data";
165
+ if (model.sections.length === 0) {
166
+ return `This audit reviewed ${subject} and found no issues with the rules that ran. The checks are deterministic and can be re-run at any time to confirm the data stays healthy.`;
167
+ }
168
+ const total = model.sections.reduce((sum, section) => sum + section.findings.length, 0);
169
+ const severityParts = SEVERITY_ORDER.filter((severity) => model.severityCounts[severity] > 0)
170
+ .map((severity) => `${model.severityCounts[severity]} ${severity}`)
171
+ .join(", ");
172
+ const scope =
173
+ model.totalRecords !== undefined
174
+ ? `${model.affectedRecords} of ${model.totalRecords} records (${percent(model.affectedRecords, model.totalRecords)}%)`
175
+ : `${model.affectedRecords} records`;
176
+ const top = model.sections[0];
177
+ const fixes =
178
+ model.operationCount > 0
179
+ ? ` ${model.operationCount} of the issues have a proposed fix ready for review; nothing is changed in the CRM until each fix is explicitly approved.`
180
+ : "";
181
+ return (
182
+ `This audit reviewed ${subject} and surfaced ${total} findings (${severityParts}) across ${scope}. ` +
183
+ `The largest issue is "${top.title}" (${top.findings.length} records).${fixes}`
184
+ );
185
+ }
186
+
187
+ function percent(part: number, whole: number): number {
188
+ if (whole === 0) return 0;
189
+ return Math.round((part / whole) * 100);
190
+ }
191
+
192
+ function findingLabel(model: ReportModel, finding: AuditFinding): string {
193
+ const name = model.recordLabels.get(`${finding.objectType}:${finding.objectId}`);
194
+ return name ? `${name} (${finding.objectType})` : `${finding.objectType}/${finding.objectId}`;
195
+ }
196
+
197
+ export function auditReportToMarkdown(plan: PatchPlan, options: ReportOptions = {}): string {
198
+ const model = buildReportModel(plan, options);
199
+ const lines: string[] = [];
200
+
201
+ lines.push(`# ${model.title}${model.clientName ? ` — ${model.clientName}` : ""}`, "");
202
+ const subtitle = [
203
+ `Prepared ${model.date}`,
204
+ model.provider ? `Source: ${model.provider}` : null,
205
+ model.preparedBy ? `By: ${model.preparedBy}` : null,
206
+ ].filter(Boolean);
207
+ lines.push(subtitle.join(" · "), "");
208
+
209
+ lines.push("## At a Glance", "", "| Metric | Value |", "| --- | --- |");
210
+ if (model.recordCounts && model.totalRecords !== undefined) {
211
+ lines.push(
212
+ `| Records audited | ${model.totalRecords} (${model.recordCounts
213
+ .map((entry) => `${entry.count} ${entry.label.toLowerCase()}`)
214
+ .join(", ")}) |`,
215
+ );
216
+ }
217
+ const totalFindings = SEVERITY_ORDER.reduce(
218
+ (sum, severity) => sum + model.severityCounts[severity],
219
+ 0,
220
+ );
221
+ lines.push(`| Findings | ${totalFindings} |`);
222
+ for (const severity of SEVERITY_ORDER) {
223
+ lines.push(`| ${capitalize(severity)} | ${model.severityCounts[severity]} |`);
224
+ }
225
+ lines.push(
226
+ `| Records affected | ${model.affectedRecords}${
227
+ model.totalRecords !== undefined && model.totalRecords > 0
228
+ ? ` (${percent(model.affectedRecords, model.totalRecords)}%)`
229
+ : ""
230
+ } |`,
231
+ `| Proposed fixes | ${model.operationCount}${
232
+ model.humanInputOperationCount > 0
233
+ ? ` (${model.humanInputOperationCount} need a human-chosen value)`
234
+ : ""
235
+ } |`,
236
+ "",
237
+ );
238
+
239
+ lines.push("## Summary", "", model.summaryText, "");
240
+
241
+ if (model.sections.length > 0) {
242
+ lines.push(
243
+ "## Findings by Rule",
244
+ "",
245
+ "| Rule | Category | Severity | Records |",
246
+ "| --- | --- | --- | --- |",
247
+ );
248
+ for (const section of model.sections) {
249
+ lines.push(
250
+ `| ${section.title} | ${section.category} | ${section.severity} | ${section.findings.length} |`,
251
+ );
252
+ }
253
+ lines.push("", "## Details", "");
254
+ for (const section of model.sections) {
255
+ lines.push(
256
+ `### ${section.title} (${section.findings.length} ${
257
+ section.findings.length === 1 ? "record" : "records"
258
+ }, ${section.severity})`,
259
+ "",
260
+ );
261
+ if (section.description) lines.push(section.description, "");
262
+ const shown = section.findings.slice(0, model.maxExamplesPerRule);
263
+ for (const finding of shown) {
264
+ lines.push(`- **${findingLabel(model, finding)}** — ${finding.summary}`);
265
+ lines.push(` - Recommendation: ${finding.recommendation}`);
266
+ }
267
+ const hidden = section.findings.length - shown.length;
268
+ if (hidden > 0) lines.push(`- … and ${hidden} more.`);
269
+ lines.push("");
270
+ }
271
+
272
+ lines.push("## Recommended Next Steps", "");
273
+ if (model.operationCount > 0) {
274
+ lines.push(
275
+ `1. Review the ${model.operationCount} proposed fixes (\`fullstackgtm audit --save\`, then \`fullstackgtm plans show <id>\`).`,
276
+ `2. Approve the operations to apply${
277
+ model.humanInputOperationCount > 0
278
+ ? `, supplying values for the ${model.humanInputOperationCount} that need a human decision`
279
+ : ""
280
+ } (\`fullstackgtm plans approve\`). Nothing is written without explicit approval.`,
281
+ "3. Re-run the audit after applying to confirm the findings clear, and schedule it recurring to catch regressions.",
282
+ );
283
+ } else {
284
+ lines.push(
285
+ "1. Address the findings above in the CRM (no automated fixes are available for them yet).",
286
+ "2. Re-run the audit to confirm the findings clear, and schedule it recurring to catch regressions.",
287
+ );
288
+ }
289
+ lines.push("");
290
+ }
291
+
292
+ lines.push(
293
+ "---",
294
+ "",
295
+ `Generated by the fullstackgtm CLI on ${model.date}.` +
296
+ (model.preparedBy ? ` Prepared by ${model.preparedBy}.` : "") +
297
+ " Findings are deterministic and re-runnable; no CRM data was modified to produce this report.",
298
+ );
299
+ return `${lines.join("\n")}\n`;
300
+ }
301
+
302
+ const SEVERITY_COLORS: Record<AuditFindingSeverity, { fg: string; bg: string }> = {
303
+ critical: { fg: "#991b1b", bg: "#fee2e2" },
304
+ warning: { fg: "#92400e", bg: "#fef3c7" },
305
+ info: { fg: "#1e40af", bg: "#dbeafe" },
306
+ };
307
+
308
+ function escapeHtml(value: string): string {
309
+ return value
310
+ .replaceAll("&", "&amp;")
311
+ .replaceAll("<", "&lt;")
312
+ .replaceAll(">", "&gt;")
313
+ .replaceAll('"', "&quot;");
314
+ }
315
+
316
+ function severityBadge(severity: AuditFindingSeverity): string {
317
+ const colors = SEVERITY_COLORS[severity];
318
+ return `<span class="badge" style="color:${colors.fg};background:${colors.bg}">${severity}</span>`;
319
+ }
320
+
321
+ function capitalize(value: string): string {
322
+ return value.charAt(0).toUpperCase() + value.slice(1);
323
+ }
324
+
325
+ /**
326
+ * Self-contained HTML (inline styles, no external assets) so the file can be
327
+ * emailed or dropped into a shared drive and render anywhere, including
328
+ * print-to-PDF.
329
+ */
330
+ export function auditReportToHtml(plan: PatchPlan, options: ReportOptions = {}): string {
331
+ const model = buildReportModel(plan, options);
332
+ const totalFindings = SEVERITY_ORDER.reduce(
333
+ (sum, severity) => sum + model.severityCounts[severity],
334
+ 0,
335
+ );
336
+
337
+ const glanceRows: string[] = [];
338
+ if (model.recordCounts && model.totalRecords !== undefined) {
339
+ glanceRows.push(
340
+ row(
341
+ "Records audited",
342
+ `${model.totalRecords} (${model.recordCounts
343
+ .map((entry) => `${entry.count} ${entry.label.toLowerCase()}`)
344
+ .join(", ")})`,
345
+ ),
346
+ );
347
+ }
348
+ glanceRows.push(row("Findings", String(totalFindings)));
349
+ for (const severity of SEVERITY_ORDER) {
350
+ glanceRows.push(
351
+ `<tr><th>${capitalize(severity)}</th><td>${model.severityCounts[severity]} ${severityBadge(severity)}</td></tr>`,
352
+ );
353
+ }
354
+ glanceRows.push(
355
+ row(
356
+ "Records affected",
357
+ `${model.affectedRecords}${
358
+ model.totalRecords !== undefined && model.totalRecords > 0
359
+ ? ` (${percent(model.affectedRecords, model.totalRecords)}%)`
360
+ : ""
361
+ }`,
362
+ ),
363
+ row(
364
+ "Proposed fixes",
365
+ `${model.operationCount}${
366
+ model.humanInputOperationCount > 0
367
+ ? ` (${model.humanInputOperationCount} need a human-chosen value)`
368
+ : ""
369
+ }`,
370
+ ),
371
+ );
372
+
373
+ const ruleRows = model.sections
374
+ .map(
375
+ (section) =>
376
+ `<tr><td>${escapeHtml(section.title)}</td><td>${escapeHtml(section.category)}</td>` +
377
+ `<td>${severityBadge(section.severity)}</td><td class="num">${section.findings.length}</td></tr>`,
378
+ )
379
+ .join("\n");
380
+
381
+ const detailSections = model.sections
382
+ .map((section) => {
383
+ const shown = section.findings.slice(0, model.maxExamplesPerRule);
384
+ const hidden = section.findings.length - shown.length;
385
+ const items = shown
386
+ .map(
387
+ (finding) =>
388
+ `<li><strong>${escapeHtml(findingLabel(model, finding))}</strong> — ${escapeHtml(finding.summary)}` +
389
+ `<div class="rec">Recommendation: ${escapeHtml(finding.recommendation)}</div></li>`,
390
+ )
391
+ .join("\n");
392
+ return [
393
+ `<section>`,
394
+ `<h3>${escapeHtml(section.title)} <span class="count">${section.findings.length} ${
395
+ section.findings.length === 1 ? "record" : "records"
396
+ }</span> ${severityBadge(section.severity)}</h3>`,
397
+ section.description ? `<p>${escapeHtml(section.description)}</p>` : "",
398
+ `<ul>`,
399
+ items,
400
+ hidden > 0 ? `<li class="more">… and ${hidden} more.</li>` : "",
401
+ `</ul>`,
402
+ `</section>`,
403
+ ]
404
+ .filter(Boolean)
405
+ .join("\n");
406
+ })
407
+ .join("\n");
408
+
409
+ const nextSteps =
410
+ model.sections.length === 0
411
+ ? ""
412
+ : model.operationCount > 0
413
+ ? `<ol>
414
+ <li>Review the ${model.operationCount} proposed fixes (<code>fullstackgtm audit --save</code>, then <code>fullstackgtm plans show &lt;id&gt;</code>).</li>
415
+ <li>Approve the operations to apply${
416
+ model.humanInputOperationCount > 0
417
+ ? `, supplying values for the ${model.humanInputOperationCount} that need a human decision`
418
+ : ""
419
+ } (<code>fullstackgtm plans approve</code>). Nothing is written without explicit approval.</li>
420
+ <li>Re-run the audit after applying to confirm the findings clear, and schedule it recurring to catch regressions.</li>
421
+ </ol>`
422
+ : `<ol>
423
+ <li>Address the findings above in the CRM (no automated fixes are available for them yet).</li>
424
+ <li>Re-run the audit to confirm the findings clear, and schedule it recurring to catch regressions.</li>
425
+ </ol>`;
426
+
427
+ const subtitle = [
428
+ `Prepared ${model.date}`,
429
+ model.provider ? `Source: ${escapeHtml(model.provider)}` : null,
430
+ model.preparedBy ? `By: ${escapeHtml(model.preparedBy)}` : null,
431
+ ]
432
+ .filter(Boolean)
433
+ .join(" · ");
434
+
435
+ return `<!doctype html>
436
+ <html lang="en">
437
+ <head>
438
+ <meta charset="utf-8">
439
+ <meta name="viewport" content="width=device-width, initial-scale=1">
440
+ <title>${escapeHtml(model.title)}${model.clientName ? ` — ${escapeHtml(model.clientName)}` : ""}</title>
441
+ <style>
442
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
443
+ color: #1f2937; max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem; line-height: 1.55; }
444
+ h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
445
+ h2 { font-size: 1.2rem; margin-top: 2rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.3rem; }
446
+ h3 { font-size: 1.05rem; margin-top: 1.5rem; }
447
+ .subtitle { color: #6b7280; margin-top: 0; }
448
+ table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
449
+ th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #e5e7eb; font-size: 0.95rem; }
450
+ th { color: #374151; font-weight: 600; }
451
+ td.num { text-align: right; }
452
+ .badge { display: inline-block; padding: 0.05rem 0.5rem; border-radius: 999px;
453
+ font-size: 0.78rem; font-weight: 600; vertical-align: middle; }
454
+ .count { color: #6b7280; font-weight: 400; font-size: 0.9rem; }
455
+ ul { padding-left: 1.2rem; }
456
+ li { margin-bottom: 0.5rem; }
457
+ .rec { color: #4b5563; font-size: 0.92rem; }
458
+ .more { color: #6b7280; list-style: none; }
459
+ code { background: #f3f4f6; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.88rem; }
460
+ footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;
461
+ color: #6b7280; font-size: 0.85rem; }
462
+ @media print { body { padding: 0; } }
463
+ </style>
464
+ </head>
465
+ <body>
466
+ <h1>${escapeHtml(model.title)}${model.clientName ? ` — ${escapeHtml(model.clientName)}` : ""}</h1>
467
+ <p class="subtitle">${subtitle}</p>
468
+
469
+ <h2>At a Glance</h2>
470
+ <table>
471
+ ${glanceRows.join("\n")}
472
+ </table>
473
+
474
+ <h2>Summary</h2>
475
+ <p>${escapeHtml(model.summaryText)}</p>
476
+ ${
477
+ model.sections.length > 0
478
+ ? `
479
+ <h2>Findings by Rule</h2>
480
+ <table>
481
+ <tr><th>Rule</th><th>Category</th><th>Severity</th><th class="num">Records</th></tr>
482
+ ${ruleRows}
483
+ </table>
484
+
485
+ <h2>Details</h2>
486
+ ${detailSections}
487
+
488
+ <h2>Recommended Next Steps</h2>
489
+ ${nextSteps}`
490
+ : ""
491
+ }
492
+ <footer>Generated by the fullstackgtm CLI on ${model.date}.${
493
+ model.preparedBy ? ` Prepared by ${escapeHtml(model.preparedBy)}.` : ""
494
+ } Findings are deterministic and re-runnable; no CRM data was modified to produce this report.</footer>
495
+ </body>
496
+ </html>
497
+ `;
498
+ }
499
+
500
+ function row(label: string, value: string): string {
501
+ return `<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
502
+ }
package/src/rules.ts CHANGED
@@ -400,6 +400,55 @@ export const duplicateContactEmailRule: GtmAuditRule = {
400
400
  },
401
401
  };
402
402
 
403
+ export const duplicateOpenDealRule: GtmAuditRule = {
404
+ id: "duplicate-open-deal",
405
+ title: "Open deals duplicate the same opportunity",
406
+ description:
407
+ "Flags multiple open deals carrying the same name (scoped to the account when linked) — " +
408
+ "usually an integration re-creating deals instead of upserting, which counts the same " +
409
+ "revenue several times in pipeline and forecast.",
410
+ category: "data-quality",
411
+ evaluate: ({ snapshot }) => {
412
+ const findings: AuditFinding[] = [];
413
+ const operations = [];
414
+ const keyOf = (deal: CanonicalDeal) => {
415
+ if (!isOpen(deal)) return undefined;
416
+ const name = deal.name?.trim().toLowerCase().replace(/\s+/g, " ");
417
+ if (!name) return undefined;
418
+ return `${deal.accountId ?? "unlinked"}:${name}`;
419
+ };
420
+ for (const [, deals] of duplicateGroups(snapshot.deals, keyOf)) {
421
+ const anchor = deals[0];
422
+ findings.push({
423
+ id: auditFindingId("duplicate-open-deal", anchor.id),
424
+ objectType: "deal",
425
+ objectId: anchor.id,
426
+ ruleId: "duplicate-open-deal",
427
+ title: "Open deals duplicate the same opportunity",
428
+ severity: "warning",
429
+ summary: `${deals.length} open deals named "${anchor.name}"${
430
+ anchor.accountId ? " on the same account" : ""
431
+ }: ${deals.map((deal) => deal.id).join(", ")}.`,
432
+ recommendation:
433
+ "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
434
+ });
435
+ operations.push({
436
+ id: patchOperationId("duplicate-open-deal", anchor.id),
437
+ objectType: "deal" as const,
438
+ objectId: anchor.id,
439
+ operation: "create_task" as const,
440
+ field: "merge_review_task",
441
+ beforeValue: null,
442
+ afterValue: `Review ${deals.length} duplicate open deals named "${anchor.name}" — keep one, archive ${deals.length - 1}`,
443
+ reason: "Duplicate open deals inflate pipeline and forecast the same revenue more than once.",
444
+ riskLevel: "medium" as const,
445
+ approvalRequired: true,
446
+ });
447
+ }
448
+ return { findings, operations };
449
+ },
450
+ };
451
+
403
452
  export const activeDealAccountWithoutContactsRule: GtmAuditRule = {
404
453
  id: "active-deal-account-without-contacts",
405
454
  title: "Account with open pipeline has no contacts",
@@ -533,6 +582,7 @@ export const builtinAuditRules: GtmAuditRule[] = [
533
582
  missingDealAmountRule,
534
583
  duplicateAccountDomainRule,
535
584
  duplicateContactEmailRule,
585
+ duplicateOpenDealRule,
536
586
  activeDealAccountWithoutContactsRule,
537
587
  closingSoonInactiveRule,
538
588
  accountSingleSourceRule,