fantasia-sh 0.1.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.
Files changed (2) hide show
  1. package/dist/index.mjs +1691 -0
  2. package/package.json +42 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,1691 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/login.ts
7
+ import { createInterface } from "readline";
8
+
9
+ // ../integrations/src/hubspot/client.ts
10
+ import { Client } from "@hubspot/api-client";
11
+ var HUBSPOT_SCOPES = [
12
+ "crm.objects.contacts.read",
13
+ "crm.objects.companies.read",
14
+ "crm.objects.deals.read",
15
+ "crm.objects.products.read",
16
+ "crm.objects.users.read",
17
+ "crm.objects.owners.read",
18
+ "crm.lists.read",
19
+ "crm.objects.leads.read"
20
+ ];
21
+ var CONTACT_PROPERTIES = [
22
+ "email",
23
+ "firstname",
24
+ "lastname",
25
+ "company",
26
+ "jobtitle",
27
+ "phone",
28
+ "hs_last_activity_date",
29
+ "notes_last_updated",
30
+ "createdate"
31
+ ];
32
+ function getHubSpotAuthUrl(opts) {
33
+ const clientId = opts?.clientId ?? process.env.HUBSPOT_CLIENT_ID;
34
+ const redirectUri = opts?.redirectUri ?? process.env.HUBSPOT_REDIRECT_URI;
35
+ const scope = HUBSPOT_SCOPES.join(" ");
36
+ const params = new URLSearchParams({
37
+ client_id: clientId,
38
+ redirect_uri: redirectUri,
39
+ scope,
40
+ response_type: "code"
41
+ });
42
+ return `https://app.hubspot.com/oauth/authorize?${params.toString()}`;
43
+ }
44
+ async function exchangeCodeForTokens(code, opts) {
45
+ const response = await fetch("https://api.hubapi.com/oauth/v1/token", {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
48
+ body: new URLSearchParams({
49
+ grant_type: "authorization_code",
50
+ client_id: opts?.clientId ?? process.env.HUBSPOT_CLIENT_ID,
51
+ client_secret: opts?.clientSecret ?? process.env.HUBSPOT_CLIENT_SECRET,
52
+ redirect_uri: opts?.redirectUri ?? process.env.HUBSPOT_REDIRECT_URI,
53
+ code
54
+ })
55
+ });
56
+ if (!response.ok) {
57
+ const error = await response.text();
58
+ throw new Error(`HubSpot token exchange failed: ${error}`);
59
+ }
60
+ return response.json();
61
+ }
62
+ async function getPortalId(accessToken) {
63
+ const response = await fetch(
64
+ `https://api.hubapi.com/oauth/v1/access-tokens/${accessToken}`
65
+ );
66
+ if (!response.ok) {
67
+ throw new Error("Failed to fetch HubSpot token info");
68
+ }
69
+ const info = await response.json();
70
+ return String(info.hub_id);
71
+ }
72
+ async function refreshAccessToken(refreshToken, opts) {
73
+ const response = await fetch("https://api.hubapi.com/oauth/v1/token", {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
76
+ body: new URLSearchParams({
77
+ grant_type: "refresh_token",
78
+ client_id: opts?.clientId ?? process.env.HUBSPOT_CLIENT_ID,
79
+ client_secret: opts?.clientSecret ?? process.env.HUBSPOT_CLIENT_SECRET,
80
+ refresh_token: refreshToken
81
+ })
82
+ });
83
+ if (!response.ok) {
84
+ const error = await response.text();
85
+ throw new Error(`HubSpot token refresh failed: ${error}`);
86
+ }
87
+ return response.json();
88
+ }
89
+ function getHubSpotClient(accessToken) {
90
+ return new Client({ accessToken });
91
+ }
92
+ async function fetchHubSpotContacts(accessToken) {
93
+ const client = getHubSpotClient(accessToken);
94
+ const contacts = [];
95
+ let after;
96
+ do {
97
+ const response = await client.crm.contacts.basicApi.getPage(
98
+ 100,
99
+ // limit per page
100
+ after,
101
+ CONTACT_PROPERTIES
102
+ );
103
+ for (const contact of response.results) {
104
+ contacts.push({
105
+ id: contact.id,
106
+ email: contact.properties.email || null,
107
+ firstname: contact.properties.firstname || null,
108
+ lastname: contact.properties.lastname || null,
109
+ company: contact.properties.company || null,
110
+ jobtitle: contact.properties.jobtitle || null,
111
+ phone: contact.properties.phone || null,
112
+ hs_last_activity_date: contact.properties.hs_last_activity_date || null,
113
+ notes_last_updated: contact.properties.notes_last_updated || null,
114
+ createdate: contact.properties.createdate || null
115
+ });
116
+ }
117
+ after = response.paging?.next?.after;
118
+ } while (after);
119
+ return contacts;
120
+ }
121
+
122
+ // src/credentials.ts
123
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
124
+ import { join } from "path";
125
+ import { homedir } from "os";
126
+ function getCredentialsPath() {
127
+ return join(homedir(), ".fantasia", "credentials.json");
128
+ }
129
+ function readCredentials() {
130
+ try {
131
+ const raw = readFileSync(getCredentialsPath(), "utf-8");
132
+ return JSON.parse(raw);
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ function writeCredentials(creds) {
138
+ const filePath = getCredentialsPath();
139
+ const dir = join(filePath, "..");
140
+ mkdirSync(dir, { recursive: true });
141
+ writeFileSync(filePath, JSON.stringify(creds, null, 2), { mode: 384 });
142
+ }
143
+ function getHubSpotTokens() {
144
+ const creds = readCredentials();
145
+ return creds?.hubspot ?? null;
146
+ }
147
+ function saveHubSpotTokens(tokens) {
148
+ const creds = readCredentials() ?? {};
149
+ creds.hubspot = tokens;
150
+ writeCredentials(creds);
151
+ }
152
+ function saveAttioTokens(tokens) {
153
+ const creds = readCredentials() ?? {};
154
+ creds.attio = tokens;
155
+ writeCredentials(creds);
156
+ }
157
+ async function ensureFreshToken(opts) {
158
+ const tokens = getHubSpotTokens();
159
+ if (!tokens) return null;
160
+ const FIVE_MINUTES = 5 * 60 * 1e3;
161
+ if (tokens.expires_at - Date.now() < FIVE_MINUTES) {
162
+ const refreshed = await refreshAccessToken(tokens.refresh_token, {
163
+ clientId: opts?.clientId,
164
+ clientSecret: opts?.clientSecret
165
+ });
166
+ const updated = {
167
+ access_token: refreshed.access_token,
168
+ refresh_token: refreshed.refresh_token,
169
+ expires_at: Date.now() + refreshed.expires_in * 1e3,
170
+ portal_id: tokens.portal_id
171
+ };
172
+ saveHubSpotTokens(updated);
173
+ return updated;
174
+ }
175
+ return tokens;
176
+ }
177
+
178
+ // src/commands/login.ts
179
+ var DEFAULT_HUBSPOT_CLIENT_ID = "6af4effd-98b7-4b3c-80b1-eab1723b37d6";
180
+ var HUBSPOT_REDIRECT_URI = "https://fantasia.sh/api/auth/cli/hubspot/callback";
181
+ var ATTIO_AUTHORIZE_URL = "https://app.attio.com/authorize";
182
+ var ATTIO_TOKEN_URL = "https://app.attio.com/oauth/token";
183
+ var ATTIO_REDIRECT_URI = "https://fantasia.sh/api/auth/cli/attio/callback";
184
+ function prompt(question) {
185
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
186
+ return new Promise((resolve) => {
187
+ rl.question(question, (answer) => {
188
+ rl.close();
189
+ resolve(answer.trim());
190
+ });
191
+ });
192
+ }
193
+ async function runLogin(options) {
194
+ if (!options.hubspot && !options.attio) {
195
+ console.log("Usage: fantasia login --hubspot");
196
+ console.log(" fantasia login --attio");
197
+ console.log(" fantasia login --attio --api-key <key>");
198
+ return;
199
+ }
200
+ const chalk = (await import("chalk")).default;
201
+ if (options.hubspot) {
202
+ await loginHubSpot(chalk);
203
+ } else if (options.attio) {
204
+ await loginAttio(chalk, options.apiKey);
205
+ }
206
+ }
207
+ async function loginHubSpot(chalk) {
208
+ const open = (await import("open")).default;
209
+ const clientId = process.env.HUBSPOT_CLIENT_ID ?? DEFAULT_HUBSPOT_CLIENT_ID;
210
+ const clientSecret = process.env.HUBSPOT_CLIENT_SECRET;
211
+ if (!clientSecret) {
212
+ console.error(
213
+ chalk.red("HUBSPOT_CLIENT_SECRET environment variable is required.")
214
+ );
215
+ process.exit(1);
216
+ }
217
+ const authUrl = getHubSpotAuthUrl({
218
+ clientId,
219
+ redirectUri: HUBSPOT_REDIRECT_URI
220
+ });
221
+ console.log(chalk.cyan("Opening browser to authorize with HubSpot..."));
222
+ console.log(chalk.dim(`(or visit: ${authUrl})`));
223
+ console.log();
224
+ open(authUrl).catch(() => {
225
+ });
226
+ const code = await prompt(
227
+ "Paste the authorization code from the browser here: "
228
+ );
229
+ if (!code) {
230
+ console.error(chalk.red("No code provided. Aborting."));
231
+ process.exit(1);
232
+ }
233
+ try {
234
+ const tokens = await exchangeCodeForTokens(code, {
235
+ clientId,
236
+ clientSecret,
237
+ redirectUri: HUBSPOT_REDIRECT_URI
238
+ });
239
+ const portalId = await getPortalId(tokens.access_token);
240
+ saveHubSpotTokens({
241
+ access_token: tokens.access_token,
242
+ refresh_token: tokens.refresh_token,
243
+ expires_at: Date.now() + tokens.expires_in * 1e3,
244
+ portal_id: portalId
245
+ });
246
+ console.log(
247
+ chalk.green("\n\u2713 Connected to HubSpot") + chalk.dim(` (portal ${portalId})`)
248
+ );
249
+ } catch (err) {
250
+ const msg = err instanceof Error ? err.message : String(err);
251
+ console.error(chalk.red(`
252
+ Login failed: ${msg}`));
253
+ process.exit(1);
254
+ }
255
+ }
256
+ async function loginAttio(chalk, apiKey) {
257
+ if (apiKey) {
258
+ return saveAttioApiKey(chalk, apiKey);
259
+ }
260
+ console.log(chalk.cyan("How would you like to connect Attio?"));
261
+ console.log(" 1. API key (from Settings \u2192 Developers \u2192 API keys)");
262
+ console.log(" 2. OAuth (authorize via browser)");
263
+ console.log();
264
+ const choice = await prompt("Choose [1/2]: ");
265
+ if (choice === "1") {
266
+ const key = await prompt("Paste your Attio API key: ");
267
+ return saveAttioApiKey(chalk, key);
268
+ }
269
+ if (choice === "2") {
270
+ return loginAttioOAuth(chalk);
271
+ }
272
+ console.error(chalk.red("Invalid choice."));
273
+ process.exit(1);
274
+ }
275
+ async function saveAttioApiKey(chalk, apiKey) {
276
+ if (!apiKey) {
277
+ console.error(chalk.red("No API key provided. Aborting."));
278
+ process.exit(1);
279
+ }
280
+ const res = await fetch("https://api.attio.com/v2/self", {
281
+ headers: { Authorization: `Bearer ${apiKey}` }
282
+ });
283
+ if (!res.ok) {
284
+ console.error(chalk.red("Invalid API key. Please check and try again."));
285
+ process.exit(1);
286
+ }
287
+ const self = await res.json();
288
+ const workspace = self.data.workspace;
289
+ saveAttioTokens({
290
+ access_token: apiKey,
291
+ auth_type: "api_key",
292
+ workspace_id: workspace.id,
293
+ workspace_name: workspace.name
294
+ });
295
+ console.log(
296
+ chalk.green("\n\u2713 Connected to Attio") + chalk.dim(` (workspace: ${workspace.name})`)
297
+ );
298
+ }
299
+ async function loginAttioOAuth(chalk) {
300
+ const open = (await import("open")).default;
301
+ const clientId = process.env.ATTIO_CLIENT_ID;
302
+ const clientSecret = process.env.ATTIO_CLIENT_SECRET;
303
+ if (!clientId || !clientSecret) {
304
+ console.error(
305
+ chalk.red(
306
+ "ATTIO_CLIENT_ID and ATTIO_CLIENT_SECRET environment variables are required for OAuth."
307
+ )
308
+ );
309
+ console.log(
310
+ chalk.dim("Tip: use --api-key instead for a simpler setup.")
311
+ );
312
+ process.exit(1);
313
+ }
314
+ const state = Math.random().toString(36).slice(2);
315
+ const params = new URLSearchParams({
316
+ client_id: clientId,
317
+ response_type: "code",
318
+ redirect_uri: ATTIO_REDIRECT_URI,
319
+ state
320
+ });
321
+ const authUrl = `${ATTIO_AUTHORIZE_URL}?${params.toString()}`;
322
+ console.log(chalk.cyan("Opening browser to authorize with Attio..."));
323
+ console.log(chalk.dim(`(or visit: ${authUrl})`));
324
+ console.log();
325
+ open(authUrl).catch(() => {
326
+ });
327
+ const code = await prompt(
328
+ "Paste the authorization code from the browser here: "
329
+ );
330
+ if (!code) {
331
+ console.error(chalk.red("No code provided. Aborting."));
332
+ process.exit(1);
333
+ }
334
+ try {
335
+ const tokenRes = await fetch(ATTIO_TOKEN_URL, {
336
+ method: "POST",
337
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
338
+ body: new URLSearchParams({
339
+ grant_type: "authorization_code",
340
+ code,
341
+ client_id: clientId,
342
+ client_secret: clientSecret,
343
+ redirect_uri: ATTIO_REDIRECT_URI
344
+ })
345
+ });
346
+ if (!tokenRes.ok) {
347
+ const error = await tokenRes.text();
348
+ throw new Error(`Token exchange failed: ${error}`);
349
+ }
350
+ const tokens = await tokenRes.json();
351
+ const selfRes = await fetch("https://api.attio.com/v2/self", {
352
+ headers: { Authorization: `Bearer ${tokens.access_token}` }
353
+ });
354
+ const self = await selfRes.json();
355
+ const workspace = self.data.workspace;
356
+ saveAttioTokens({
357
+ access_token: tokens.access_token,
358
+ auth_type: "oauth",
359
+ workspace_id: workspace.id,
360
+ workspace_name: workspace.name
361
+ });
362
+ console.log(
363
+ chalk.green("\n\u2713 Connected to Attio") + chalk.dim(` (workspace: ${workspace.name})`)
364
+ );
365
+ } catch (err) {
366
+ const msg = err instanceof Error ? err.message : String(err);
367
+ console.error(chalk.red(`
368
+ Login failed: ${msg}`));
369
+ process.exit(1);
370
+ }
371
+ }
372
+
373
+ // ../integrations/src/audit/checks/duplicates.ts
374
+ function levenshtein(a, b) {
375
+ const m = a.length;
376
+ const n = b.length;
377
+ const dp = Array.from(
378
+ { length: m + 1 },
379
+ () => Array(n + 1).fill(0)
380
+ );
381
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
382
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
383
+ for (let i = 1; i <= m; i++) {
384
+ for (let j = 1; j <= n; j++) {
385
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
386
+ }
387
+ }
388
+ return dp[m][n];
389
+ }
390
+ function normalizeName(first, last) {
391
+ return `${(first ?? "").trim()} ${(last ?? "").trim()}`.toLowerCase().replace(/\s+/g, " ").trim();
392
+ }
393
+ function stripMiddleInitials(name) {
394
+ return name.replace(/\b[a-z]\.?\s+/g, (match, offset) => {
395
+ return offset > 0 ? "" : match;
396
+ }).replace(/\s+/g, " ").trim();
397
+ }
398
+ function emailDomain(email) {
399
+ if (!email) return null;
400
+ const parts = email.toLowerCase().split("@");
401
+ return parts.length === 2 ? parts[1] : null;
402
+ }
403
+ function normalizeEmailUsername(email) {
404
+ if (!email) return null;
405
+ const atIndex = email.indexOf("@");
406
+ if (atIndex < 1) return null;
407
+ let username = email.substring(0, atIndex).toLowerCase();
408
+ const plusIndex = username.indexOf("+");
409
+ if (plusIndex >= 0) username = username.substring(0, plusIndex);
410
+ username = username.replace(/\./g, "");
411
+ return username || null;
412
+ }
413
+ function normalizePhone(phone) {
414
+ if (!phone) return null;
415
+ const digits = phone.replace(/\D/g, "");
416
+ return digits.length >= 7 ? digits : null;
417
+ }
418
+ var UnionFind = class {
419
+ constructor() {
420
+ this.parent = /* @__PURE__ */ new Map();
421
+ }
422
+ find(x) {
423
+ if (!this.parent.has(x)) this.parent.set(x, x);
424
+ if (this.parent.get(x) !== x) {
425
+ this.parent.set(x, this.find(this.parent.get(x)));
426
+ }
427
+ return this.parent.get(x);
428
+ }
429
+ union(a, b) {
430
+ const ra = this.find(a);
431
+ const rb = this.find(b);
432
+ if (ra !== rb) this.parent.set(ra, rb);
433
+ }
434
+ };
435
+ function getNameData(c) {
436
+ const name = normalizeName(c.firstname, c.lastname);
437
+ if (!name) return null;
438
+ return { name, stripped: stripMiddleInitials(name) };
439
+ }
440
+ function bestNameDistance(a, b) {
441
+ return Math.min(
442
+ levenshtein(a.name, b.name),
443
+ levenshtein(a.stripped, b.stripped)
444
+ );
445
+ }
446
+ function compareBucket(bucket, nameCache, uf, matcher) {
447
+ for (let i = 0; i < bucket.length; i++) {
448
+ for (let j = i + 1; j < bucket.length; j++) {
449
+ const a = bucket[i];
450
+ const b = bucket[j];
451
+ const ndA = nameCache.get(a.id);
452
+ const ndB = nameCache.get(b.id);
453
+ if (!ndA || !ndB) continue;
454
+ const distFn = () => bestNameDistance(ndA, ndB);
455
+ if (matcher(a, b, distFn)) {
456
+ uf.union(a.id, b.id);
457
+ }
458
+ }
459
+ }
460
+ }
461
+ function detectDuplicates(contacts) {
462
+ const uf = new UnionFind();
463
+ const nameCache = /* @__PURE__ */ new Map();
464
+ for (const c of contacts) {
465
+ nameCache.set(c.id, getNameData(c));
466
+ }
467
+ const emailIndex = /* @__PURE__ */ new Map();
468
+ const domainIndex = /* @__PURE__ */ new Map();
469
+ const usernameIndex = /* @__PURE__ */ new Map();
470
+ const companyIndex = /* @__PURE__ */ new Map();
471
+ const phoneIndex = /* @__PURE__ */ new Map();
472
+ for (const c of contacts) {
473
+ if (c.email) {
474
+ const key = c.email.toLowerCase();
475
+ const list = emailIndex.get(key) ?? [];
476
+ list.push(c);
477
+ emailIndex.set(key, list);
478
+ const domain = emailDomain(c.email);
479
+ if (domain) {
480
+ const dList = domainIndex.get(domain) ?? [];
481
+ dList.push(c);
482
+ domainIndex.set(domain, dList);
483
+ }
484
+ const username = normalizeEmailUsername(c.email);
485
+ if (username && username.length >= 3) {
486
+ const uList = usernameIndex.get(username) ?? [];
487
+ uList.push(c);
488
+ usernameIndex.set(username, uList);
489
+ }
490
+ }
491
+ const company = c.company?.toLowerCase().trim();
492
+ if (company) {
493
+ const cList = companyIndex.get(company) ?? [];
494
+ cList.push(c);
495
+ companyIndex.set(company, cList);
496
+ }
497
+ const phone = normalizePhone(c.phone);
498
+ if (phone) {
499
+ const pList = phoneIndex.get(phone) ?? [];
500
+ pList.push(c);
501
+ phoneIndex.set(phone, pList);
502
+ }
503
+ }
504
+ for (const bucket of emailIndex.values()) {
505
+ if (bucket.length > 1) {
506
+ for (let i = 1; i < bucket.length; i++) {
507
+ uf.union(bucket[0].id, bucket[i].id);
508
+ }
509
+ }
510
+ }
511
+ for (const bucket of domainIndex.values()) {
512
+ if (bucket.length < 2) continue;
513
+ compareBucket(bucket, nameCache, uf, (_a, _b, distFn) => distFn() <= 2);
514
+ }
515
+ for (const bucket of usernameIndex.values()) {
516
+ if (bucket.length < 2) continue;
517
+ compareBucket(bucket, nameCache, uf, (a, b, distFn) => {
518
+ const compA = a.company?.toLowerCase().trim();
519
+ const compB = b.company?.toLowerCase().trim();
520
+ const companiesDiffer = compA && compB && compA !== compB;
521
+ return !companiesDiffer && distFn() <= 2;
522
+ });
523
+ }
524
+ for (const bucket of companyIndex.values()) {
525
+ if (bucket.length < 2) continue;
526
+ compareBucket(bucket, nameCache, uf, (_a, _b, distFn) => distFn() <= 2);
527
+ }
528
+ for (const bucket of phoneIndex.values()) {
529
+ if (bucket.length < 2) continue;
530
+ compareBucket(bucket, nameCache, uf, (_a, _b, distFn) => distFn() <= 4);
531
+ }
532
+ const clusters = /* @__PURE__ */ new Map();
533
+ for (const c of contacts) {
534
+ uf.find(c.id);
535
+ const root = uf.find(c.id);
536
+ const list = clusters.get(root) ?? [];
537
+ list.push(c.id);
538
+ clusters.set(root, list);
539
+ }
540
+ const contactMap = new Map(contacts.map((c) => [c.id, c]));
541
+ const issues = [];
542
+ for (const [, members] of clusters) {
543
+ if (members.length < 2) continue;
544
+ const severity = members.length >= 4 ? "critical" : members.length >= 3 ? "high" : "medium";
545
+ const primaryId = members[0];
546
+ const primary = contactMap.get(primaryId);
547
+ issues.push({
548
+ category: "duplicate",
549
+ severity,
550
+ record_id: primaryId,
551
+ details: {
552
+ cluster_size: members.length,
553
+ cluster_ids: members,
554
+ primary_email: primary.email,
555
+ primary_name: normalizeName(primary.firstname, primary.lastname),
556
+ match_type: determineMatchType(members, contactMap)
557
+ }
558
+ });
559
+ }
560
+ return issues;
561
+ }
562
+ function determineMatchType(ids, contactMap) {
563
+ const contacts = ids.map((id) => contactMap.get(id));
564
+ const emails = contacts.map((c) => c.email?.toLowerCase()).filter(Boolean);
565
+ const uniqueEmails = new Set(emails);
566
+ if (uniqueEmails.size < emails.length) return "exact_email";
567
+ const usernames = contacts.map((c) => normalizeEmailUsername(c.email)).filter(Boolean);
568
+ const uniqueUsernames = new Set(usernames);
569
+ if (uniqueUsernames.size < usernames.length) return "email_username";
570
+ const companies = contacts.map((c) => c.company?.toLowerCase().trim()).filter(Boolean);
571
+ const uniqueCompanies = new Set(companies);
572
+ if (uniqueCompanies.size === 1 && companies.length > 1) return "company_name";
573
+ const phones = contacts.map((c) => normalizePhone(c.phone)).filter(Boolean);
574
+ const uniquePhones = new Set(phones);
575
+ if (uniquePhones.size < phones.length) return "phone_match";
576
+ return "fuzzy_name_domain";
577
+ }
578
+ function countDuplicateAffectedRecords(issues) {
579
+ const seen = /* @__PURE__ */ new Set();
580
+ for (const issue of issues) {
581
+ const ids = issue.details.cluster_ids ?? [];
582
+ for (const id of ids) seen.add(id);
583
+ }
584
+ return seen.size;
585
+ }
586
+
587
+ // ../integrations/src/audit/checks/stale.ts
588
+ var STALE_THRESHOLD_DAYS = 90;
589
+ function detectStaleRecords(contacts) {
590
+ const now = Date.now();
591
+ const thresholdMs = STALE_THRESHOLD_DAYS * 864e5;
592
+ const issues = [];
593
+ for (const c of contacts) {
594
+ if (c.createdate) {
595
+ const createdAt = new Date(c.createdate).getTime();
596
+ if (now - createdAt < thresholdMs) continue;
597
+ }
598
+ const lastActivity = c.hs_last_activity_date ? new Date(c.hs_last_activity_date).getTime() : null;
599
+ const lastNotes = c.notes_last_updated ? new Date(c.notes_last_updated).getTime() : null;
600
+ const mostRecent = Math.max(lastActivity ?? 0, lastNotes ?? 0);
601
+ const dataQualityNotes = [];
602
+ if (!c.hs_last_activity_date && !c.notes_last_updated) {
603
+ dataQualityNotes.push("No activity dates on record \u2014 may indicate a data import without engagement history");
604
+ } else {
605
+ if (!c.hs_last_activity_date) {
606
+ dataQualityNotes.push("Missing hs_last_activity_date \u2014 activity tracking may not be configured");
607
+ }
608
+ if (!c.notes_last_updated) {
609
+ dataQualityNotes.push("Missing notes_last_updated \u2014 no notes have been logged");
610
+ }
611
+ }
612
+ const twoYearsMs = 730 * 864e5;
613
+ if (lastActivity && now - lastActivity > twoYearsMs) {
614
+ dataQualityNotes.push("Last activity is over 2 years ago \u2014 consider archiving or re-engaging");
615
+ }
616
+ if (mostRecent === 0 || now - mostRecent > thresholdMs) {
617
+ const daysSinceActivity = mostRecent === 0 ? null : Math.floor((now - mostRecent) / 864e5);
618
+ issues.push({
619
+ category: "stale",
620
+ severity: daysSinceActivity === null || daysSinceActivity > 180 ? "high" : "medium",
621
+ record_id: c.id,
622
+ details: {
623
+ days_since_activity: daysSinceActivity,
624
+ last_activity_date: c.hs_last_activity_date,
625
+ last_notes_updated: c.notes_last_updated,
626
+ contact_name: [c.firstname, c.lastname].filter(Boolean).join(" "),
627
+ contact_email: c.email,
628
+ ...dataQualityNotes.length > 0 && { data_quality_notes: dataQualityNotes }
629
+ }
630
+ });
631
+ }
632
+ }
633
+ return issues;
634
+ }
635
+
636
+ // ../integrations/src/audit/checks/missing-fields.ts
637
+ var REQUIRED_FIELDS = [
638
+ "email",
639
+ "company",
640
+ "jobtitle",
641
+ "phone"
642
+ ];
643
+ function detectMissingFields(contacts) {
644
+ const issues = [];
645
+ for (const c of contacts) {
646
+ const missing = [];
647
+ for (const field of REQUIRED_FIELDS) {
648
+ const val = c[field];
649
+ if (val === null || val === void 0 || typeof val === "string" && val.trim() === "") {
650
+ missing.push(field);
651
+ }
652
+ }
653
+ if (missing.length === 0) continue;
654
+ const ratio = missing.length / REQUIRED_FIELDS.length;
655
+ const severity = ratio >= 0.75 ? "critical" : ratio >= 0.5 ? "high" : "medium";
656
+ issues.push({
657
+ category: "missing_field",
658
+ severity,
659
+ record_id: c.id,
660
+ details: {
661
+ missing_fields: missing,
662
+ missing_count: missing.length,
663
+ total_required: REQUIRED_FIELDS.length,
664
+ completeness_pct: Math.round((1 - ratio) * 100),
665
+ contact_name: [c.firstname, c.lastname].filter(Boolean).join(" "),
666
+ contact_email: c.email
667
+ }
668
+ });
669
+ }
670
+ return issues;
671
+ }
672
+
673
+ // ../integrations/src/audit/checks/format.ts
674
+ function detectNameCasing(name) {
675
+ if (!name || name.trim() === "") return null;
676
+ const trimmed = name.trim();
677
+ if (trimmed === trimmed.toLowerCase()) return "lower_case";
678
+ if (trimmed === trimmed.toUpperCase()) return "upper_case";
679
+ if (/^([A-Z][a-z]*(?:'[A-Z][a-z]*)*\.?[-\s]*)+$/.test(trimmed)) return "title_case";
680
+ return "mixed";
681
+ }
682
+ function detectPhoneFormat(phone) {
683
+ if (!phone || phone.trim() === "") return null;
684
+ const p = phone.trim();
685
+ if (/^\(\d{3}\)\s?\d{3}-\d{4}$/.test(p)) return "parenthesized";
686
+ if (/^\d{3}-\d{3}-\d{4}$/.test(p)) return "dashed";
687
+ if (/^\d{3}\.\d{3}\.\d{4}$/.test(p)) return "dotted";
688
+ if (/^\d{10,11}$/.test(p)) return "digits_only";
689
+ if (/^\+/.test(p)) return "international";
690
+ return "other";
691
+ }
692
+ function majorityFormat(values) {
693
+ const counts = /* @__PURE__ */ new Map();
694
+ for (const v of values) {
695
+ if (v === null) continue;
696
+ counts.set(v, (counts.get(v) ?? 0) + 1);
697
+ }
698
+ let best = null;
699
+ let bestCount = 0;
700
+ for (const [k, c] of counts) {
701
+ if (c > bestCount) {
702
+ best = k;
703
+ bestCount = c;
704
+ }
705
+ }
706
+ return best;
707
+ }
708
+ function detectFormatIssues(contacts) {
709
+ const firstNameCasings = contacts.map((c) => detectNameCasing(c.firstname));
710
+ const lastNameCasings = contacts.map((c) => detectNameCasing(c.lastname));
711
+ const phoneFormats = contacts.map((c) => detectPhoneFormat(c.phone));
712
+ const majorityFirstName = majorityFormat(firstNameCasings);
713
+ const majorityLastName = majorityFormat(lastNameCasings);
714
+ const majorityPhone = majorityFormat(phoneFormats);
715
+ const issues = [];
716
+ for (let i = 0; i < contacts.length; i++) {
717
+ const c = contacts[i];
718
+ const problems = [];
719
+ const details = {
720
+ contact_name: [c.firstname, c.lastname].filter(Boolean).join(" "),
721
+ contact_email: c.email
722
+ };
723
+ const fnCase = firstNameCasings[i];
724
+ if (fnCase && majorityFirstName && fnCase !== majorityFirstName) {
725
+ problems.push("firstname_casing");
726
+ details.firstname_casing = fnCase;
727
+ details.expected_firstname_casing = majorityFirstName;
728
+ }
729
+ const lnCase = lastNameCasings[i];
730
+ if (lnCase && majorityLastName && lnCase !== majorityLastName) {
731
+ problems.push("lastname_casing");
732
+ details.lastname_casing = lnCase;
733
+ details.expected_lastname_casing = majorityLastName;
734
+ }
735
+ const pFmt = phoneFormats[i];
736
+ if (pFmt && majorityPhone && pFmt !== majorityPhone) {
737
+ problems.push("phone_format");
738
+ details.phone_format = pFmt;
739
+ details.expected_phone_format = majorityPhone;
740
+ details.phone_value = c.phone;
741
+ }
742
+ if (problems.length > 0) {
743
+ details.issues = problems;
744
+ issues.push({
745
+ category: "format",
746
+ severity: problems.length >= 2 ? "medium" : "low",
747
+ record_id: c.id,
748
+ details
749
+ });
750
+ }
751
+ }
752
+ return issues;
753
+ }
754
+
755
+ // ../integrations/src/audit/scoring.ts
756
+ var WEIGHTS = {
757
+ duplicates: 0.3,
758
+ stale: 0.25,
759
+ missingFields: 0.25,
760
+ format: 0.2
761
+ };
762
+ function categoryScore(affectedCount, totalRecords) {
763
+ if (totalRecords === 0) return 100;
764
+ const pct = affectedCount / totalRecords * 100;
765
+ if (pct === 0) return 100;
766
+ if (pct <= 5) return 85;
767
+ if (pct <= 15) return 70;
768
+ if (pct <= 30) return 50;
769
+ if (pct <= 50) return 30;
770
+ return 10;
771
+ }
772
+ function computeCategoryScore(affectedCount, totalRecords) {
773
+ return {
774
+ score: categoryScore(affectedCount, totalRecords),
775
+ affectedCount,
776
+ totalRecords,
777
+ percentAffected: totalRecords === 0 ? 0 : Math.round(affectedCount / totalRecords * 1e3) / 10
778
+ };
779
+ }
780
+ function computeHealthScore(categories) {
781
+ const composite = Math.round(
782
+ categories.duplicates.score * WEIGHTS.duplicates + categories.stale.score * WEIGHTS.stale + categories.missingFields.score * WEIGHTS.missingFields + categories.format.score * WEIGHTS.format
783
+ );
784
+ const score = Math.max(0, Math.min(100, composite));
785
+ let grade;
786
+ if (score >= 90) grade = "A";
787
+ else if (score >= 80) grade = "B";
788
+ else if (score >= 70) grade = "C";
789
+ else if (score >= 55) grade = "D";
790
+ else grade = "F";
791
+ return { score, grade };
792
+ }
793
+
794
+ // ../integrations/src/audit/mock-data.ts
795
+ function getMockContacts() {
796
+ const now = /* @__PURE__ */ new Date();
797
+ const daysAgo = (n) => new Date(now.getTime() - n * 864e5).toISOString();
798
+ return [
799
+ // --- Clean records ---
800
+ {
801
+ id: "101",
802
+ email: "alice@acme.com",
803
+ firstname: "Alice",
804
+ lastname: "Johnson",
805
+ company: "Acme Corp",
806
+ jobtitle: "VP of Sales",
807
+ phone: "(555) 123-4567",
808
+ hs_last_activity_date: daysAgo(5),
809
+ notes_last_updated: daysAgo(3),
810
+ createdate: daysAgo(200)
811
+ },
812
+ {
813
+ id: "102",
814
+ email: "bob@globex.com",
815
+ firstname: "Bob",
816
+ lastname: "Smith",
817
+ company: "Globex Inc",
818
+ jobtitle: "CTO",
819
+ phone: "(555) 234-5678",
820
+ hs_last_activity_date: daysAgo(10),
821
+ notes_last_updated: daysAgo(7),
822
+ createdate: daysAgo(180)
823
+ },
824
+ // --- Duplicates: exact email ---
825
+ {
826
+ id: "103",
827
+ email: "charlie@wayne.com",
828
+ firstname: "Charlie",
829
+ lastname: "Brown",
830
+ company: "Wayne Enterprises",
831
+ jobtitle: "Director of Ops",
832
+ phone: "(555) 345-6789",
833
+ hs_last_activity_date: daysAgo(15),
834
+ notes_last_updated: daysAgo(12),
835
+ createdate: daysAgo(300)
836
+ },
837
+ {
838
+ id: "104",
839
+ email: "Charlie@Wayne.com",
840
+ // duplicate email (case diff)
841
+ firstname: "Charles",
842
+ lastname: "Brown",
843
+ company: "Wayne Enterprises",
844
+ jobtitle: "Dir. Operations",
845
+ phone: "555-345-6789",
846
+ hs_last_activity_date: daysAgo(20),
847
+ notes_last_updated: daysAgo(18),
848
+ createdate: daysAgo(250)
849
+ },
850
+ // --- Duplicates: fuzzy name + same domain ---
851
+ {
852
+ id: "105",
853
+ email: "diana@stark.com",
854
+ firstname: "Diana",
855
+ lastname: "Prince",
856
+ company: "Stark Industries",
857
+ jobtitle: "Head of Marketing",
858
+ phone: "(555) 456-7890",
859
+ hs_last_activity_date: daysAgo(8),
860
+ notes_last_updated: daysAgo(6),
861
+ createdate: daysAgo(150)
862
+ },
863
+ {
864
+ id: "106",
865
+ email: "dianap@stark.com",
866
+ // same domain, similar name
867
+ firstname: "Dina",
868
+ // Levenshtein 1 from "Diana"
869
+ lastname: "Prince",
870
+ company: "Stark Industries",
871
+ jobtitle: "Marketing Lead",
872
+ phone: "(555) 456-7891",
873
+ hs_last_activity_date: daysAgo(12),
874
+ notes_last_updated: daysAgo(10),
875
+ createdate: daysAgo(140)
876
+ },
877
+ // --- Stale records (no activity in 90+ days, created > 90 days ago) ---
878
+ {
879
+ id: "107",
880
+ email: "edward@oldcorp.com",
881
+ firstname: "Edward",
882
+ lastname: "Norton",
883
+ company: "OldCorp LLC",
884
+ jobtitle: "Account Executive",
885
+ phone: "(555) 567-8901",
886
+ hs_last_activity_date: daysAgo(120),
887
+ notes_last_updated: daysAgo(150),
888
+ createdate: daysAgo(400)
889
+ },
890
+ {
891
+ id: "108",
892
+ email: "frank@dusty.com",
893
+ firstname: "Frank",
894
+ lastname: "Castle",
895
+ company: "Dusty Deals Inc",
896
+ jobtitle: "Sales Rep",
897
+ phone: "(555) 678-9012",
898
+ hs_last_activity_date: null,
899
+ notes_last_updated: null,
900
+ createdate: daysAgo(365)
901
+ },
902
+ {
903
+ id: "109",
904
+ email: "grace@fossil.com",
905
+ firstname: "Grace",
906
+ lastname: "Hopper",
907
+ company: "Fossil Data Corp",
908
+ jobtitle: "Analyst",
909
+ phone: "(555) 789-0123",
910
+ hs_last_activity_date: daysAgo(200),
911
+ notes_last_updated: daysAgo(195),
912
+ createdate: daysAgo(500)
913
+ },
914
+ // --- New record (created < 90 days ago, no activity — should NOT be flagged as stale) ---
915
+ {
916
+ id: "110",
917
+ email: "hank@newco.com",
918
+ firstname: "Hank",
919
+ lastname: "Pym",
920
+ company: "NewCo",
921
+ jobtitle: "Founder",
922
+ phone: "(555) 890-1234",
923
+ hs_last_activity_date: null,
924
+ notes_last_updated: null,
925
+ createdate: daysAgo(30)
926
+ },
927
+ // --- Missing critical fields ---
928
+ {
929
+ id: "111",
930
+ email: null,
931
+ // missing email
932
+ firstname: "Ivy",
933
+ lastname: "League",
934
+ company: "Mystery Inc",
935
+ jobtitle: null,
936
+ // missing jobtitle
937
+ phone: null,
938
+ // missing phone
939
+ hs_last_activity_date: daysAgo(5),
940
+ notes_last_updated: daysAgo(3),
941
+ createdate: daysAgo(100)
942
+ },
943
+ {
944
+ id: "112",
945
+ email: "jack@incomplete.com",
946
+ firstname: "Jack",
947
+ lastname: "Sparrow",
948
+ company: null,
949
+ // missing company
950
+ jobtitle: null,
951
+ // missing jobtitle
952
+ phone: null,
953
+ // missing phone
954
+ hs_last_activity_date: daysAgo(15),
955
+ notes_last_updated: daysAgo(10),
956
+ createdate: daysAgo(200)
957
+ },
958
+ {
959
+ id: "113",
960
+ email: null,
961
+ // missing email
962
+ firstname: "Karen",
963
+ lastname: null,
964
+ // missing lastname (not in required but name matters)
965
+ company: null,
966
+ // missing company
967
+ jobtitle: null,
968
+ // missing jobtitle
969
+ phone: null,
970
+ // missing phone
971
+ hs_last_activity_date: daysAgo(2),
972
+ notes_last_updated: daysAgo(1),
973
+ createdate: daysAgo(50)
974
+ },
975
+ // --- Format inconsistencies ---
976
+ {
977
+ id: "114",
978
+ email: "leo@format.com",
979
+ firstname: "leo",
980
+ // lowercase name
981
+ lastname: "messi",
982
+ company: "Format Co",
983
+ jobtitle: "Engineer",
984
+ phone: "5551234567",
985
+ // no formatting
986
+ hs_last_activity_date: daysAgo(3),
987
+ notes_last_updated: daysAgo(2),
988
+ createdate: daysAgo(100)
989
+ },
990
+ {
991
+ id: "115",
992
+ email: "maria@format.com",
993
+ firstname: "MARIA",
994
+ // ALL CAPS
995
+ lastname: "GARCIA",
996
+ company: "Format Co",
997
+ jobtitle: "Designer",
998
+ phone: "+1-555-987-6543",
999
+ // international format
1000
+ hs_last_activity_date: daysAgo(7),
1001
+ notes_last_updated: daysAgo(5),
1002
+ createdate: daysAgo(90)
1003
+ },
1004
+ // --- More clean records to dilute percentages ---
1005
+ {
1006
+ id: "116",
1007
+ email: "nick@clean.com",
1008
+ firstname: "Nick",
1009
+ lastname: "Fury",
1010
+ company: "Shield Corp",
1011
+ jobtitle: "Director",
1012
+ phone: "(555) 111-2222",
1013
+ hs_last_activity_date: daysAgo(2),
1014
+ notes_last_updated: daysAgo(1),
1015
+ createdate: daysAgo(300)
1016
+ },
1017
+ {
1018
+ id: "117",
1019
+ email: "olivia@clean.com",
1020
+ firstname: "Olivia",
1021
+ lastname: "Pope",
1022
+ company: "OPA Consulting",
1023
+ jobtitle: "Managing Partner",
1024
+ phone: "(555) 222-3333",
1025
+ hs_last_activity_date: daysAgo(1),
1026
+ notes_last_updated: daysAgo(1),
1027
+ createdate: daysAgo(250)
1028
+ },
1029
+ {
1030
+ id: "118",
1031
+ email: "peter@clean.com",
1032
+ firstname: "Peter",
1033
+ lastname: "Parker",
1034
+ company: "Daily Bugle",
1035
+ jobtitle: "Photographer",
1036
+ phone: "(555) 333-4444",
1037
+ hs_last_activity_date: daysAgo(4),
1038
+ notes_last_updated: daysAgo(3),
1039
+ createdate: daysAgo(180)
1040
+ },
1041
+ {
1042
+ id: "119",
1043
+ email: "quinn@clean.com",
1044
+ firstname: "Quinn",
1045
+ lastname: "Hughes",
1046
+ company: "Canucks Ltd",
1047
+ jobtitle: "Defenseman",
1048
+ phone: "(555) 444-5555",
1049
+ hs_last_activity_date: daysAgo(6),
1050
+ notes_last_updated: daysAgo(4),
1051
+ createdate: daysAgo(120)
1052
+ },
1053
+ {
1054
+ id: "120",
1055
+ email: "rachel@clean.com",
1056
+ firstname: "Rachel",
1057
+ lastname: "Green",
1058
+ company: "Ralph Lauren",
1059
+ jobtitle: "Buyer",
1060
+ phone: "(555) 555-6666",
1061
+ hs_last_activity_date: daysAgo(3),
1062
+ notes_last_updated: daysAgo(2),
1063
+ createdate: daysAgo(400)
1064
+ }
1065
+ ];
1066
+ }
1067
+
1068
+ // ../integrations/src/audit/engine.ts
1069
+ function runAudit(options) {
1070
+ const contacts = options?.contacts ?? getMockContacts();
1071
+ const totalRecords = contacts.length;
1072
+ const duplicateIssues = detectDuplicates(contacts);
1073
+ const staleIssues = detectStaleRecords(contacts);
1074
+ const missingFieldIssues = detectMissingFields(contacts);
1075
+ const formatIssues = detectFormatIssues(contacts);
1076
+ const duplicateAffected = countDuplicateAffectedRecords(duplicateIssues);
1077
+ const staleAffected = staleIssues.length;
1078
+ const missingFieldAffected = missingFieldIssues.length;
1079
+ const formatAffected = formatIssues.length;
1080
+ const categories = {
1081
+ duplicates: computeCategoryScore(duplicateAffected, totalRecords),
1082
+ stale: computeCategoryScore(staleAffected, totalRecords),
1083
+ missingFields: computeCategoryScore(missingFieldAffected, totalRecords),
1084
+ format: computeCategoryScore(formatAffected, totalRecords)
1085
+ };
1086
+ const { score, grade } = computeHealthScore(categories);
1087
+ return {
1088
+ healthScore: score,
1089
+ grade,
1090
+ categories,
1091
+ issues: [
1092
+ ...duplicateIssues,
1093
+ ...staleIssues,
1094
+ ...missingFieldIssues,
1095
+ ...formatIssues
1096
+ ],
1097
+ totalRecords
1098
+ };
1099
+ }
1100
+
1101
+ // src/commands/audit.ts
1102
+ function renderBar(score, width = 20) {
1103
+ const filled = Math.round(score / 100 * width);
1104
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
1105
+ }
1106
+ function severityOrder(s) {
1107
+ switch (s) {
1108
+ case "critical":
1109
+ return 0;
1110
+ case "high":
1111
+ return 1;
1112
+ case "medium":
1113
+ return 2;
1114
+ case "low":
1115
+ return 3;
1116
+ default:
1117
+ return 4;
1118
+ }
1119
+ }
1120
+ function categoryLabel(cat) {
1121
+ switch (cat) {
1122
+ case "duplicate":
1123
+ return "Duplicates";
1124
+ case "stale":
1125
+ return "Stale";
1126
+ case "missing_field":
1127
+ return "Missing Fields";
1128
+ case "format":
1129
+ return "Format";
1130
+ default:
1131
+ return cat;
1132
+ }
1133
+ }
1134
+ async function runAudit2(options) {
1135
+ const chalk = (await import("chalk")).default;
1136
+ const tokens = await ensureFreshToken();
1137
+ if (!tokens) {
1138
+ console.error(
1139
+ chalk.red("Not logged in. Run `fantasia login --hubspot` first.")
1140
+ );
1141
+ process.exit(1);
1142
+ }
1143
+ console.log(chalk.dim("Fetching contacts..."));
1144
+ const contacts = await fetchHubSpotContacts(tokens.access_token);
1145
+ console.log(chalk.dim(`Running audit on ${contacts.length} contacts...`));
1146
+ const result = runAudit({ contacts });
1147
+ if (options.json) {
1148
+ console.log(JSON.stringify(result, null, 2));
1149
+ const threshold2 = parseInt(options.threshold ?? "0", 10);
1150
+ process.exit(result.healthScore >= threshold2 ? 0 : 1);
1151
+ return;
1152
+ }
1153
+ const gradeColor = (grade) => {
1154
+ if (grade === "A" || grade === "B") return chalk.green;
1155
+ if (grade === "C") return chalk.yellow;
1156
+ return chalk.red;
1157
+ };
1158
+ const scoreColor = (n) => n >= 80 ? chalk.green : n >= 60 ? chalk.yellow : chalk.red;
1159
+ const barColor = (n, bar2) => n >= 80 ? chalk.green(bar2) : n >= 60 ? chalk.yellow(bar2) : chalk.red(bar2);
1160
+ console.log();
1161
+ console.log(chalk.bold.white(" FANTASIA CRM AUDIT"));
1162
+ console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1163
+ const bar = renderBar(result.healthScore, 30);
1164
+ console.log(
1165
+ `
1166
+ Health Score ${barColor(result.healthScore, bar)} ${chalk.bold(
1167
+ scoreColor(result.healthScore)(String(result.healthScore))
1168
+ )}/100 ${gradeColor(result.grade)(result.grade)}`
1169
+ );
1170
+ console.log();
1171
+ console.log(chalk.dim(" Categories:"));
1172
+ const catEntries = [
1173
+ {
1174
+ label: "Duplicates",
1175
+ score: result.categories.duplicates.score,
1176
+ affected: result.categories.duplicates.affectedCount
1177
+ },
1178
+ {
1179
+ label: "Stale",
1180
+ score: result.categories.stale.score,
1181
+ affected: result.categories.stale.affectedCount
1182
+ },
1183
+ {
1184
+ label: "Missing Fields",
1185
+ score: result.categories.missingFields.score,
1186
+ affected: result.categories.missingFields.affectedCount
1187
+ },
1188
+ {
1189
+ label: "Format",
1190
+ score: result.categories.format.score,
1191
+ affected: result.categories.format.affectedCount
1192
+ }
1193
+ ];
1194
+ for (const cat of catEntries) {
1195
+ const catBar = renderBar(cat.score, 20);
1196
+ const label = cat.label.padEnd(18);
1197
+ console.log(
1198
+ ` ${chalk.dim(label)} ${barColor(cat.score, catBar)} ${scoreColor(
1199
+ cat.score
1200
+ )(String(cat.score))}/100 ${chalk.dim(`(${cat.affected} affected)`)}`
1201
+ );
1202
+ }
1203
+ const sortedIssues = [...result.issues].sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity)).slice(0, 5);
1204
+ if (sortedIssues.length > 0) {
1205
+ console.log();
1206
+ console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1207
+ console.log(chalk.bold(" Top Issues:"));
1208
+ sortedIssues.forEach((issue, i) => {
1209
+ const sevColor = issue.severity === "critical" || issue.severity === "high" ? chalk.red : issue.severity === "medium" ? chalk.yellow : chalk.dim;
1210
+ console.log(
1211
+ ` ${chalk.cyan(`${i + 1}.`)} ${sevColor(
1212
+ `[${issue.severity.toUpperCase()}]`
1213
+ )} ${categoryLabel(issue.category)} \u2014 record ${chalk.dim(
1214
+ issue.record_id
1215
+ )}`
1216
+ );
1217
+ });
1218
+ }
1219
+ console.log();
1220
+ console.log(
1221
+ chalk.dim(
1222
+ ` ${result.totalRecords} records scanned | Portal ${tokens.portal_id}`
1223
+ )
1224
+ );
1225
+ console.log();
1226
+ const threshold = parseInt(options.threshold ?? "0", 10);
1227
+ process.exit(result.healthScore >= threshold ? 0 : 1);
1228
+ }
1229
+
1230
+ // src/commands/fix.ts
1231
+ import readline from "readline";
1232
+
1233
+ // ../integrations/src/fix/merge.ts
1234
+ var MERGEABLE_FIELDS = [
1235
+ "email",
1236
+ "firstname",
1237
+ "lastname",
1238
+ "company",
1239
+ "jobtitle",
1240
+ "phone"
1241
+ ];
1242
+ async function buildMergePreview(issues, hubspotClient) {
1243
+ const clusters = [];
1244
+ for (const issue of issues) {
1245
+ const clusterIds = issue.details.cluster_ids ?? issue.hubspotRecordIds;
1246
+ if (!clusterIds || clusterIds.length < 2) continue;
1247
+ const contacts = await Promise.all(
1248
+ clusterIds.map(async (id) => {
1249
+ try {
1250
+ const resp = await hubspotClient.crm.contacts.basicApi.getById(id, MERGEABLE_FIELDS);
1251
+ return { id, properties: resp.properties };
1252
+ } catch {
1253
+ return { id, properties: {} };
1254
+ }
1255
+ })
1256
+ );
1257
+ const scored = contacts.map((c) => ({
1258
+ ...c,
1259
+ score: Object.values(c.properties).filter((v) => v && v.trim()).length
1260
+ }));
1261
+ scored.sort((a, b) => b.score - a.score);
1262
+ const keepId = scored[0].id;
1263
+ const mergeIds = scored.slice(1).map((c) => c.id);
1264
+ const primaryProps = scored[0].properties;
1265
+ const mergedFields = {};
1266
+ const discardedFields = {};
1267
+ for (const secondary of scored.slice(1)) {
1268
+ for (const field of MERGEABLE_FIELDS) {
1269
+ const primaryVal = primaryProps[field];
1270
+ const secondaryVal = secondary.properties[field];
1271
+ if (!secondaryVal || !secondaryVal.trim()) continue;
1272
+ if (!primaryVal || !primaryVal.trim()) {
1273
+ if (!mergedFields[field]) {
1274
+ mergedFields[field] = { from: secondary.id, value: secondaryVal };
1275
+ }
1276
+ } else if (primaryVal.trim() !== secondaryVal.trim()) {
1277
+ if (!discardedFields[field]) discardedFields[field] = [];
1278
+ discardedFields[field].push({
1279
+ from: secondary.id,
1280
+ value: secondaryVal,
1281
+ primaryValue: primaryVal
1282
+ });
1283
+ }
1284
+ }
1285
+ }
1286
+ clusters.push({
1287
+ issueId: issue._id,
1288
+ contactIds: clusterIds,
1289
+ keepId,
1290
+ mergeIds,
1291
+ mergedFields,
1292
+ discardedFields: Object.keys(discardedFields).length > 0 ? discardedFields : void 0
1293
+ });
1294
+ }
1295
+ return { type: "merge_duplicates", clusters };
1296
+ }
1297
+ async function executeMerge(preview, hubspotClient, accessToken) {
1298
+ const entries = [];
1299
+ for (const cluster of preview.clusters) {
1300
+ for (const contactId of cluster.contactIds) {
1301
+ try {
1302
+ const resp = await hubspotClient.crm.contacts.basicApi.getById(
1303
+ contactId,
1304
+ MERGEABLE_FIELDS
1305
+ );
1306
+ entries.push({
1307
+ contactId,
1308
+ originalValues: { ...resp.properties }
1309
+ });
1310
+ } catch {
1311
+ entries.push({ contactId, originalValues: {} });
1312
+ }
1313
+ }
1314
+ if (Object.keys(cluster.mergedFields).length > 0) {
1315
+ const updateProps = {};
1316
+ for (const [field, info] of Object.entries(cluster.mergedFields)) {
1317
+ updateProps[field] = info.value;
1318
+ }
1319
+ await hubspotClient.crm.contacts.basicApi.update(cluster.keepId, {
1320
+ properties: updateProps
1321
+ });
1322
+ }
1323
+ for (const mergeId of cluster.mergeIds) {
1324
+ await fetch("https://api.hubapi.com/crm/v3/objects/contacts/merge", {
1325
+ method: "POST",
1326
+ headers: {
1327
+ "Content-Type": "application/json",
1328
+ Authorization: `Bearer ${accessToken}`
1329
+ },
1330
+ body: JSON.stringify({
1331
+ primaryObjectId: cluster.keepId,
1332
+ objectIdToMerge: mergeId
1333
+ })
1334
+ });
1335
+ }
1336
+ }
1337
+ return {
1338
+ type: "merge_duplicates",
1339
+ timestamp: Date.now(),
1340
+ entries
1341
+ };
1342
+ }
1343
+
1344
+ // ../integrations/src/fix/normalize.ts
1345
+ function toTitleCase(name) {
1346
+ return name.trim().toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
1347
+ }
1348
+ function normalizePhone2(phone) {
1349
+ const digits = phone.replace(/\D/g, "");
1350
+ if (digits.length === 10) {
1351
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
1352
+ }
1353
+ if (digits.length === 11 && digits.startsWith("1")) {
1354
+ return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
1355
+ }
1356
+ if (digits.length > 10) {
1357
+ return `+${digits}`;
1358
+ }
1359
+ return phone.trim();
1360
+ }
1361
+ async function buildNormalizePreview(issues, hubspotClient) {
1362
+ const changes = [];
1363
+ for (const issue of issues) {
1364
+ const contactId = issue.hubspotRecordIds[0];
1365
+ if (!contactId) continue;
1366
+ const issueList = issue.details.issues ?? [];
1367
+ if (issueList.length === 0) continue;
1368
+ let currentProps;
1369
+ try {
1370
+ const resp = await hubspotClient.crm.contacts.basicApi.getById(contactId, [
1371
+ "firstname",
1372
+ "lastname",
1373
+ "phone"
1374
+ ]);
1375
+ currentProps = resp.properties;
1376
+ } catch {
1377
+ continue;
1378
+ }
1379
+ const fields = [];
1380
+ for (const issueName of issueList) {
1381
+ const firstname = currentProps.firstname;
1382
+ const lastname = currentProps.lastname;
1383
+ const phone = currentProps.phone;
1384
+ if (issueName === "firstname_casing" && firstname) {
1385
+ const newVal = toTitleCase(firstname);
1386
+ if (newVal !== firstname) {
1387
+ fields.push({ field: "firstname", currentValue: firstname, newValue: newVal });
1388
+ }
1389
+ }
1390
+ if (issueName === "lastname_casing" && lastname) {
1391
+ const newVal = toTitleCase(lastname);
1392
+ if (newVal !== lastname) {
1393
+ fields.push({ field: "lastname", currentValue: lastname, newValue: newVal });
1394
+ }
1395
+ }
1396
+ if (issueName === "phone_format" && phone) {
1397
+ const newVal = normalizePhone2(phone);
1398
+ if (newVal !== phone) {
1399
+ fields.push({ field: "phone", currentValue: phone, newValue: newVal });
1400
+ }
1401
+ }
1402
+ }
1403
+ if (fields.length > 0) {
1404
+ changes.push({ issueId: issue._id, contactId, fields });
1405
+ }
1406
+ }
1407
+ return { type: "normalize_format", changes };
1408
+ }
1409
+ async function executeNormalize(preview, hubspotClient) {
1410
+ const entries = [];
1411
+ for (const change of preview.changes) {
1412
+ const originalValues = {};
1413
+ for (const f of change.fields) {
1414
+ originalValues[f.field] = f.currentValue;
1415
+ }
1416
+ entries.push({ contactId: change.contactId, originalValues });
1417
+ const updateProps = {};
1418
+ for (const f of change.fields) {
1419
+ updateProps[f.field] = f.newValue;
1420
+ }
1421
+ await hubspotClient.crm.contacts.basicApi.update(change.contactId, {
1422
+ properties: updateProps
1423
+ });
1424
+ }
1425
+ return {
1426
+ type: "normalize_format",
1427
+ timestamp: Date.now(),
1428
+ entries
1429
+ };
1430
+ }
1431
+
1432
+ // src/commands/fix.ts
1433
+ function askConfirmation(question) {
1434
+ const rl = readline.createInterface({
1435
+ input: process.stdin,
1436
+ output: process.stdout
1437
+ });
1438
+ return new Promise((resolve) => {
1439
+ rl.question(question, (answer) => {
1440
+ rl.close();
1441
+ resolve(answer.toLowerCase() === "y");
1442
+ });
1443
+ });
1444
+ }
1445
+ async function runFix(options) {
1446
+ const chalk = (await import("chalk")).default;
1447
+ const tokens = await ensureFreshToken();
1448
+ if (!tokens) {
1449
+ console.error(
1450
+ chalk.red("Not logged in. Run `fantasia login --hubspot` first.")
1451
+ );
1452
+ process.exit(1);
1453
+ }
1454
+ console.log(chalk.dim("Fetching contacts..."));
1455
+ const contacts = await fetchHubSpotContacts(tokens.access_token);
1456
+ console.log(chalk.dim(`Running audit on ${contacts.length} contacts...`));
1457
+ const auditResult = runAudit({ contacts });
1458
+ if (auditResult.issues.length === 0) {
1459
+ if (options.json) {
1460
+ console.log(JSON.stringify({ fixes: [], message: "No issues found!" }, null, 2));
1461
+ } else {
1462
+ console.log(chalk.green("No issues found!"));
1463
+ }
1464
+ process.exit(0);
1465
+ }
1466
+ const duplicateIssues = auditResult.issues.filter((i) => i.category === "duplicate").map((i) => ({
1467
+ _id: `${i.category}-${i.record_id}`,
1468
+ hubspotRecordIds: [i.record_id],
1469
+ details: i.details
1470
+ }));
1471
+ const formatIssues = auditResult.issues.filter((i) => i.category === "format").map((i) => ({
1472
+ _id: `${i.category}-${i.record_id}`,
1473
+ hubspotRecordIds: [i.record_id],
1474
+ details: i.details
1475
+ }));
1476
+ const hubspotClient = getHubSpotClient(tokens.access_token);
1477
+ console.log(chalk.dim("Building fix previews..."));
1478
+ let mergePreview = null;
1479
+ let normalizePreview = null;
1480
+ if (duplicateIssues.length > 0) {
1481
+ mergePreview = await buildMergePreview(duplicateIssues, hubspotClient);
1482
+ }
1483
+ if (formatIssues.length > 0) {
1484
+ normalizePreview = await buildNormalizePreview(formatIssues, hubspotClient);
1485
+ }
1486
+ const totalFixes = (mergePreview?.clusters.length ?? 0) + (normalizePreview?.changes.length ?? 0);
1487
+ if (totalFixes === 0) {
1488
+ if (options.json) {
1489
+ console.log(JSON.stringify({ fixes: [], message: "No actionable fixes found." }, null, 2));
1490
+ } else {
1491
+ console.log(chalk.green("No actionable fixes found."));
1492
+ }
1493
+ process.exit(0);
1494
+ }
1495
+ if (options.json) {
1496
+ const preview = {
1497
+ merges: mergePreview?.clusters ?? [],
1498
+ normalizations: normalizePreview?.changes ?? [],
1499
+ totalFixes
1500
+ };
1501
+ if (!options.execute) {
1502
+ console.log(JSON.stringify(preview, null, 2));
1503
+ return;
1504
+ }
1505
+ } else {
1506
+ console.log();
1507
+ console.log(chalk.bold.white(" FANTASIA CRM FIX PREVIEW"));
1508
+ console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1509
+ if (mergePreview && mergePreview.clusters.length > 0) {
1510
+ console.log();
1511
+ console.log(
1512
+ chalk.bold(` Duplicate Merges: ${mergePreview.clusters.length}`)
1513
+ );
1514
+ for (const cluster of mergePreview.clusters) {
1515
+ console.log(
1516
+ ` ${chalk.cyan("Keep:")} ${cluster.keepId} ${chalk.dim(
1517
+ "Merge:"
1518
+ )} ${cluster.mergeIds.join(", ")}`
1519
+ );
1520
+ if (Object.keys(cluster.mergedFields).length > 0) {
1521
+ for (const [field, info] of Object.entries(cluster.mergedFields)) {
1522
+ console.log(
1523
+ chalk.dim(` + ${field}: "${info.value}" from ${info.from}`)
1524
+ );
1525
+ }
1526
+ }
1527
+ }
1528
+ }
1529
+ if (normalizePreview && normalizePreview.changes.length > 0) {
1530
+ console.log();
1531
+ console.log(
1532
+ chalk.bold(
1533
+ ` Format Normalizations: ${normalizePreview.changes.length}`
1534
+ )
1535
+ );
1536
+ for (const change of normalizePreview.changes) {
1537
+ console.log(` ${chalk.cyan("Contact:")} ${change.contactId}`);
1538
+ for (const f of change.fields) {
1539
+ console.log(
1540
+ ` ${chalk.dim(f.field)}: ${chalk.red(
1541
+ `"${f.currentValue}"`
1542
+ )} ${chalk.dim("->")} ${chalk.green(`"${f.newValue}"`)}`
1543
+ );
1544
+ }
1545
+ }
1546
+ }
1547
+ console.log();
1548
+ console.log(chalk.dim(` Total fixes: ${totalFixes}`));
1549
+ console.log();
1550
+ }
1551
+ if (!options.execute) {
1552
+ if (!options.json) {
1553
+ console.log(
1554
+ chalk.dim(" Run with --execute to apply fixes.")
1555
+ );
1556
+ console.log();
1557
+ }
1558
+ return;
1559
+ }
1560
+ if (!options.yes) {
1561
+ const confirmed = await askConfirmation(
1562
+ ` Execute ${totalFixes} fixes? (y/N) `
1563
+ );
1564
+ if (!confirmed) {
1565
+ console.log(chalk.dim(" Aborted."));
1566
+ return;
1567
+ }
1568
+ }
1569
+ console.log(chalk.dim(" Executing fixes..."));
1570
+ const results = {};
1571
+ if (mergePreview && mergePreview.clusters.length > 0) {
1572
+ const snapshot = await executeMerge(
1573
+ mergePreview,
1574
+ hubspotClient,
1575
+ tokens.access_token
1576
+ );
1577
+ results.mergeSnapshot = snapshot;
1578
+ if (!options.json) {
1579
+ console.log(
1580
+ chalk.green(
1581
+ ` Merged ${mergePreview.clusters.length} duplicate cluster(s).`
1582
+ )
1583
+ );
1584
+ }
1585
+ }
1586
+ if (normalizePreview && normalizePreview.changes.length > 0) {
1587
+ const snapshot = await executeNormalize(normalizePreview, hubspotClient);
1588
+ results.normalizeSnapshot = snapshot;
1589
+ if (!options.json) {
1590
+ console.log(
1591
+ chalk.green(
1592
+ ` Normalized ${normalizePreview.changes.length} contact(s).`
1593
+ )
1594
+ );
1595
+ }
1596
+ }
1597
+ if (options.json) {
1598
+ console.log(
1599
+ JSON.stringify(
1600
+ { executed: true, totalFixes, ...results },
1601
+ null,
1602
+ 2
1603
+ )
1604
+ );
1605
+ } else {
1606
+ console.log();
1607
+ console.log(chalk.bold.green(` Done! ${totalFixes} fix(es) applied.`));
1608
+ console.log();
1609
+ }
1610
+ }
1611
+
1612
+ // src/commands/status.ts
1613
+ async function runStatus(options) {
1614
+ const chalk = (await import("chalk")).default;
1615
+ const creds = readCredentials();
1616
+ const hubspot = creds?.hubspot ?? null;
1617
+ const attio = creds?.attio ?? null;
1618
+ if (options.json) {
1619
+ console.log(
1620
+ JSON.stringify(
1621
+ {
1622
+ integrations: {
1623
+ hubspot: hubspot ? {
1624
+ connected: true,
1625
+ portal_id: hubspot.portal_id,
1626
+ expires_at: hubspot.expires_at
1627
+ } : { connected: false },
1628
+ attio: attio ? {
1629
+ connected: true,
1630
+ auth_type: attio.auth_type,
1631
+ workspace_id: attio.workspace_id,
1632
+ workspace_name: attio.workspace_name
1633
+ } : { connected: false }
1634
+ }
1635
+ },
1636
+ null,
1637
+ 2
1638
+ )
1639
+ );
1640
+ return;
1641
+ }
1642
+ console.log();
1643
+ console.log(chalk.bold.white(" Integrations:"));
1644
+ console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1645
+ if (hubspot) {
1646
+ const expiresIn = hubspot.expires_at - Date.now();
1647
+ const expiresMinutes = Math.round(expiresIn / 6e4);
1648
+ const expiryText = expiresIn > 0 ? `token expires in ${expiresMinutes} min` : "token expired";
1649
+ console.log(
1650
+ ` HubSpot: ${chalk.green("Connected")} ${chalk.dim(
1651
+ `(portal ${hubspot.portal_id}, ${expiryText})`
1652
+ )}`
1653
+ );
1654
+ } else {
1655
+ console.log(` HubSpot: ${chalk.dim("Not connected")}`);
1656
+ }
1657
+ if (attio) {
1658
+ console.log(
1659
+ ` Attio: ${chalk.green("Connected")} ${chalk.dim(
1660
+ `(${attio.workspace_name}, ${attio.auth_type})`
1661
+ )}`
1662
+ );
1663
+ } else {
1664
+ console.log(` Attio: ${chalk.dim("Not connected")}`);
1665
+ }
1666
+ console.log();
1667
+ if (!hubspot && !attio) {
1668
+ console.log(chalk.dim(" Run `fantasia login --hubspot` or `fantasia login --attio` to connect."));
1669
+ }
1670
+ console.log();
1671
+ }
1672
+
1673
+ // src/index.ts
1674
+ var pkg = { name: "fantasia", version: "0.1.0" };
1675
+ var program = new Command();
1676
+ program.name(pkg.name).description("CLI-first CRM data enrichment & hygiene").version(pkg.version);
1677
+ program.command("login").description("Authenticate with a CRM integration").option("--hubspot", "Connect to HubSpot via OAuth").option("--attio", "Connect to Attio (API key or OAuth)").option("--api-key <key>", "Attio API key (skip interactive prompt)").action(async (options) => {
1678
+ await runLogin(options);
1679
+ });
1680
+ program.command("audit").description("Run a CRM data-quality audit").option("--json", "Output results as JSON").option("--threshold <score>", "Minimum passing health score (exit 1 if below)").action(async (options) => {
1681
+ await runAudit2(options);
1682
+ });
1683
+ program.command("fix").description("Preview and apply auto-fixes for CRM data issues").option("--execute", "Apply the fixes (default: dry-run preview)").option("--json", "Output results as JSON").option("-y, --yes", "Skip confirmation prompt").action(
1684
+ async (options) => {
1685
+ await runFix(options);
1686
+ }
1687
+ );
1688
+ program.command("status").description("Show connection status for integrations").option("--json", "Output results as JSON").action(async (options) => {
1689
+ await runStatus(options);
1690
+ });
1691
+ program.parse(process.argv);