@hasna/contacts 0.2.2 → 0.2.3
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/dist/cli/index.js +144 -14
- package/dist/db/companies.d.ts +2 -0
- package/dist/db/companies.d.ts.map +1 -1
- package/dist/db/contacts.d.ts +7 -1
- package/dist/db/contacts.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/groups.d.ts +11 -1
- package/dist/db/groups.d.ts.map +1 -1
- package/dist/db/tags.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +274 -16
- package/dist/mcp/index.js +665 -31
- package/dist/server/index.js +115 -10
- package/dist/types/index.d.ts +41 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +21 -30
package/dist/mcp/index.js
CHANGED
|
@@ -198,6 +198,20 @@ var MIGRATIONS = [
|
|
|
198
198
|
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
199
199
|
PRIMARY KEY (contact_id, group_id)
|
|
200
200
|
);
|
|
201
|
+
`,
|
|
202
|
+
`
|
|
203
|
+
ALTER TABLE contacts ADD COLUMN status TEXT DEFAULT 'active';
|
|
204
|
+
ALTER TABLE contacts ADD COLUMN follow_up_at TEXT;
|
|
205
|
+
ALTER TABLE contacts ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
|
206
|
+
ALTER TABLE contacts ADD COLUMN project_id TEXT;
|
|
207
|
+
ALTER TABLE companies ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
|
208
|
+
ALTER TABLE companies ADD COLUMN project_id TEXT;
|
|
209
|
+
|
|
210
|
+
CREATE TABLE IF NOT EXISTS company_groups (
|
|
211
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
212
|
+
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
213
|
+
PRIMARY KEY (company_id, group_id)
|
|
214
|
+
);
|
|
201
215
|
`
|
|
202
216
|
];
|
|
203
217
|
var _db = null;
|
|
@@ -269,11 +283,32 @@ class DuplicateTagNameError extends Error {
|
|
|
269
283
|
}
|
|
270
284
|
|
|
271
285
|
// src/db/activity.ts
|
|
286
|
+
function rowToActivity(row) {
|
|
287
|
+
return { ...row };
|
|
288
|
+
}
|
|
272
289
|
function logActivity(db, input) {
|
|
273
290
|
const id = uuid();
|
|
274
291
|
db.run(`INSERT INTO activity_log (id, contact_id, company_id, action, details) VALUES (?, ?, ?, ?, ?)`, [id, input.contact_id ?? null, input.company_id ?? null, input.action, input.details ?? null]);
|
|
275
292
|
return db.query(`SELECT * FROM activity_log WHERE id = ?`).get(id);
|
|
276
293
|
}
|
|
294
|
+
function listActivity(opts = {}, db) {
|
|
295
|
+
const d = db || getDatabase();
|
|
296
|
+
const { limit = 50, offset = 0, contact_id, company_id } = opts;
|
|
297
|
+
const conditions = [];
|
|
298
|
+
const params = [];
|
|
299
|
+
if (contact_id) {
|
|
300
|
+
conditions.push("contact_id = ?");
|
|
301
|
+
params.push(contact_id);
|
|
302
|
+
}
|
|
303
|
+
if (company_id) {
|
|
304
|
+
conditions.push("company_id = ?");
|
|
305
|
+
params.push(company_id);
|
|
306
|
+
}
|
|
307
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
308
|
+
const totalRow = d.query(`SELECT COUNT(*) as total FROM activity_log ${where}`).get(...params);
|
|
309
|
+
const rows = d.query(`SELECT * FROM activity_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
310
|
+
return { entries: rows.map(rowToActivity), total: totalRow.total };
|
|
311
|
+
}
|
|
277
312
|
|
|
278
313
|
// src/db/contacts.ts
|
|
279
314
|
function rowToContact(row) {
|
|
@@ -281,7 +316,11 @@ function rowToContact(row) {
|
|
|
281
316
|
...row,
|
|
282
317
|
source: row.source,
|
|
283
318
|
custom_fields: JSON.parse(row.custom_fields || "{}"),
|
|
284
|
-
preferred_contact_method: row.preferred_contact_method ?? null
|
|
319
|
+
preferred_contact_method: row.preferred_contact_method ?? null,
|
|
320
|
+
status: row.status ?? "active",
|
|
321
|
+
follow_up_at: row.follow_up_at ?? null,
|
|
322
|
+
archived: !!row.archived,
|
|
323
|
+
project_id: row.project_id ?? null
|
|
285
324
|
};
|
|
286
325
|
}
|
|
287
326
|
function rowToEmail(row) {
|
|
@@ -318,7 +357,9 @@ function rowToTag(row) {
|
|
|
318
357
|
function rowToCompany(row) {
|
|
319
358
|
return {
|
|
320
359
|
...row,
|
|
321
|
-
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
360
|
+
custom_fields: JSON.parse(row.custom_fields || "{}"),
|
|
361
|
+
archived: !!row.archived,
|
|
362
|
+
project_id: row.project_id ?? null
|
|
322
363
|
};
|
|
323
364
|
}
|
|
324
365
|
function insertEmails(db, contactId, companyId, emails) {
|
|
@@ -362,8 +403,8 @@ function createContact(input, db) {
|
|
|
362
403
|
const firstName = input.first_name ?? "";
|
|
363
404
|
const lastName = input.last_name ?? "";
|
|
364
405
|
const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
|
|
365
|
-
d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, created_at, updated_at)
|
|
366
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
406
|
+
d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, status, follow_up_at, project_id, created_at, updated_at)
|
|
407
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
367
408
|
id,
|
|
368
409
|
firstName,
|
|
369
410
|
lastName,
|
|
@@ -379,6 +420,9 @@ function createContact(input, db) {
|
|
|
379
420
|
input.last_contacted_at ?? null,
|
|
380
421
|
input.website ?? null,
|
|
381
422
|
input.preferred_contact_method ?? null,
|
|
423
|
+
input.status ?? "active",
|
|
424
|
+
input.follow_up_at ?? null,
|
|
425
|
+
input.project_id ?? null,
|
|
382
426
|
timestamp,
|
|
383
427
|
timestamp
|
|
384
428
|
]);
|
|
@@ -413,12 +457,21 @@ function listContacts(opts = {}, db) {
|
|
|
413
457
|
offset = 0,
|
|
414
458
|
company_id,
|
|
415
459
|
tag_id,
|
|
460
|
+
tag_ids,
|
|
416
461
|
source,
|
|
462
|
+
status,
|
|
463
|
+
project_id,
|
|
464
|
+
archived = false,
|
|
465
|
+
follow_up_due,
|
|
466
|
+
last_contacted_after,
|
|
467
|
+
last_contacted_before,
|
|
417
468
|
order_by = "display_name",
|
|
418
469
|
order_dir = "asc"
|
|
419
470
|
} = opts;
|
|
420
471
|
const conditions = [];
|
|
421
472
|
const params = [];
|
|
473
|
+
conditions.push("c.archived = ?");
|
|
474
|
+
params.push(archived ? 1 : 0);
|
|
422
475
|
if (company_id) {
|
|
423
476
|
conditions.push("c.company_id = ?");
|
|
424
477
|
params.push(company_id);
|
|
@@ -427,12 +480,37 @@ function listContacts(opts = {}, db) {
|
|
|
427
480
|
conditions.push("c.source = ?");
|
|
428
481
|
params.push(source);
|
|
429
482
|
}
|
|
483
|
+
if (status) {
|
|
484
|
+
conditions.push("c.status = ?");
|
|
485
|
+
params.push(status);
|
|
486
|
+
}
|
|
487
|
+
if (project_id) {
|
|
488
|
+
conditions.push("c.project_id = ?");
|
|
489
|
+
params.push(project_id);
|
|
490
|
+
}
|
|
430
491
|
if (tag_id) {
|
|
431
492
|
conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
|
|
432
493
|
params.push(tag_id);
|
|
433
494
|
}
|
|
495
|
+
if (tag_ids?.length) {
|
|
496
|
+
for (const tid of tag_ids) {
|
|
497
|
+
conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
|
|
498
|
+
params.push(tid);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (follow_up_due) {
|
|
502
|
+
conditions.push("c.follow_up_at IS NOT NULL AND c.follow_up_at <= datetime('now')");
|
|
503
|
+
}
|
|
504
|
+
if (last_contacted_after) {
|
|
505
|
+
conditions.push("c.last_contacted_at >= ?");
|
|
506
|
+
params.push(last_contacted_after);
|
|
507
|
+
}
|
|
508
|
+
if (last_contacted_before) {
|
|
509
|
+
conditions.push("c.last_contacted_at <= ?");
|
|
510
|
+
params.push(last_contacted_before);
|
|
511
|
+
}
|
|
434
512
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
435
|
-
const validOrderBy = ["display_name", "created_at", "updated_at"].includes(order_by) ? order_by : "display_name";
|
|
513
|
+
const validOrderBy = ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"].includes(order_by) ? order_by : "display_name";
|
|
436
514
|
const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
|
|
437
515
|
const totalRow = d.query(`SELECT COUNT(*) as total FROM contacts c ${where}`).get(...params);
|
|
438
516
|
const rows = d.query(`SELECT c.* FROM contacts c ${where} ORDER BY c.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
@@ -502,8 +580,36 @@ function updateContact(id, input, db) {
|
|
|
502
580
|
setClauses.push("preferred_contact_method = ?");
|
|
503
581
|
params.push(input.preferred_contact_method);
|
|
504
582
|
}
|
|
583
|
+
if (input.status !== undefined) {
|
|
584
|
+
setClauses.push("status = ?");
|
|
585
|
+
params.push(input.status);
|
|
586
|
+
}
|
|
587
|
+
if (input.follow_up_at !== undefined) {
|
|
588
|
+
setClauses.push("follow_up_at = ?");
|
|
589
|
+
params.push(input.follow_up_at);
|
|
590
|
+
}
|
|
591
|
+
if (input.project_id !== undefined) {
|
|
592
|
+
setClauses.push("project_id = ?");
|
|
593
|
+
params.push(input.project_id);
|
|
594
|
+
}
|
|
505
595
|
params.push(id);
|
|
506
596
|
d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
597
|
+
if (input.emails_add?.length) {
|
|
598
|
+
for (const e of input.emails_add) {
|
|
599
|
+
const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(id, e.address);
|
|
600
|
+
if (!exists) {
|
|
601
|
+
d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), id, e.address, e.type ?? "work", e.is_primary ? 1 : 0]);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (input.phones_add?.length) {
|
|
606
|
+
for (const p of input.phones_add) {
|
|
607
|
+
const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(id, p.number);
|
|
608
|
+
if (!exists) {
|
|
609
|
+
d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), id, p.number, p.country_code ?? null, p.type ?? "mobile", p.is_primary ? 1 : 0]);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
507
613
|
logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
|
|
508
614
|
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
509
615
|
return loadContactDetails(d, rowToContact(row));
|
|
@@ -521,25 +627,31 @@ function searchContacts(query, db) {
|
|
|
521
627
|
const ftsRows = d.query(`
|
|
522
628
|
SELECT c.* FROM contacts c
|
|
523
629
|
JOIN contacts_fts fts ON fts.id = c.id
|
|
524
|
-
WHERE contacts_fts MATCH ?
|
|
630
|
+
WHERE contacts_fts MATCH ? AND c.archived = 0
|
|
525
631
|
ORDER BY rank
|
|
526
632
|
LIMIT 50
|
|
527
633
|
`).all(`"${query.replace(/"/g, '""')}"*`);
|
|
528
634
|
const emailRows = d.query(`
|
|
529
635
|
SELECT DISTINCT c.* FROM contacts c
|
|
530
636
|
JOIN emails e ON e.contact_id = c.id
|
|
531
|
-
WHERE e.address LIKE ?
|
|
637
|
+
WHERE e.address LIKE ? AND c.archived = 0
|
|
532
638
|
LIMIT 20
|
|
533
639
|
`).all(`%${query}%`);
|
|
534
640
|
const phoneRows = d.query(`
|
|
535
641
|
SELECT DISTINCT c.* FROM contacts c
|
|
536
642
|
JOIN phones p ON p.contact_id = c.id
|
|
537
|
-
WHERE p.number LIKE ?
|
|
643
|
+
WHERE p.number LIKE ? AND c.archived = 0
|
|
644
|
+
LIMIT 20
|
|
645
|
+
`).all(`%${query}%`);
|
|
646
|
+
const companyRows = d.query(`
|
|
647
|
+
SELECT DISTINCT c.* FROM contacts c
|
|
648
|
+
JOIN companies co ON co.id = c.company_id
|
|
649
|
+
WHERE co.name LIKE ? AND c.archived = 0
|
|
538
650
|
LIMIT 20
|
|
539
651
|
`).all(`%${query}%`);
|
|
540
652
|
const seen = new Set;
|
|
541
653
|
const allRows = [];
|
|
542
|
-
for (const row of [...ftsRows, ...emailRows, ...phoneRows]) {
|
|
654
|
+
for (const row of [...ftsRows, ...emailRows, ...phoneRows, ...companyRows]) {
|
|
543
655
|
if (!seen.has(row.id)) {
|
|
544
656
|
seen.add(row.id);
|
|
545
657
|
allRows.push(row);
|
|
@@ -555,8 +667,24 @@ function mergeContacts(keepId, mergeId, db) {
|
|
|
555
667
|
const mergeRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(mergeId);
|
|
556
668
|
if (!mergeRow)
|
|
557
669
|
throw new ContactNotFoundError(mergeId);
|
|
558
|
-
d.
|
|
559
|
-
|
|
670
|
+
const mergeEmailRows = d.query(`SELECT * FROM emails WHERE contact_id = ?`).all(mergeId);
|
|
671
|
+
for (const e of mergeEmailRows) {
|
|
672
|
+
const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(keepId, e.address);
|
|
673
|
+
if (exists) {
|
|
674
|
+
d.run(`DELETE FROM emails WHERE id = ?`, [e.id]);
|
|
675
|
+
} else {
|
|
676
|
+
d.run(`UPDATE emails SET contact_id = ? WHERE id = ?`, [keepId, e.id]);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const mergePhoneRows = d.query(`SELECT * FROM phones WHERE contact_id = ?`).all(mergeId);
|
|
680
|
+
for (const p of mergePhoneRows) {
|
|
681
|
+
const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(keepId, p.number);
|
|
682
|
+
if (exists) {
|
|
683
|
+
d.run(`DELETE FROM phones WHERE id = ?`, [p.id]);
|
|
684
|
+
} else {
|
|
685
|
+
d.run(`UPDATE phones SET contact_id = ? WHERE id = ?`, [keepId, p.id]);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
560
688
|
d.run(`UPDATE addresses SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
561
689
|
d.run(`UPDATE social_profiles SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
562
690
|
const mergeTags = d.query(`SELECT tag_id FROM contact_tags WHERE contact_id = ?`).all(mergeId);
|
|
@@ -603,6 +731,79 @@ function mergeContacts(keepId, mergeId, db) {
|
|
|
603
731
|
const finalRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
|
|
604
732
|
return loadContactDetails(d, rowToContact(finalRow));
|
|
605
733
|
}
|
|
734
|
+
function getContactByEmail(email, db) {
|
|
735
|
+
const d = db || getDatabase();
|
|
736
|
+
const emailRow = d.query(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(email);
|
|
737
|
+
if (!emailRow)
|
|
738
|
+
return null;
|
|
739
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(emailRow.contact_id);
|
|
740
|
+
if (!row)
|
|
741
|
+
return null;
|
|
742
|
+
return loadContactDetails(d, rowToContact(row));
|
|
743
|
+
}
|
|
744
|
+
function addEmailToContact(contactId, email, db) {
|
|
745
|
+
const d = db || getDatabase();
|
|
746
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
747
|
+
if (!row)
|
|
748
|
+
throw new ContactNotFoundError(contactId);
|
|
749
|
+
const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(contactId, email.address);
|
|
750
|
+
if (!exists) {
|
|
751
|
+
d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), contactId, email.address, email.type ?? "work", email.is_primary ? 1 : 0]);
|
|
752
|
+
}
|
|
753
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
754
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
755
|
+
}
|
|
756
|
+
function addPhoneToContact(contactId, phone, db) {
|
|
757
|
+
const d = db || getDatabase();
|
|
758
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
759
|
+
if (!row)
|
|
760
|
+
throw new ContactNotFoundError(contactId);
|
|
761
|
+
const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(contactId, phone.number);
|
|
762
|
+
if (!exists) {
|
|
763
|
+
d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), contactId, phone.number, phone.country_code ?? null, phone.type ?? "mobile", phone.is_primary ? 1 : 0]);
|
|
764
|
+
}
|
|
765
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
766
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
767
|
+
}
|
|
768
|
+
function archiveContact(id, db) {
|
|
769
|
+
const d = db || getDatabase();
|
|
770
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
771
|
+
if (!row)
|
|
772
|
+
throw new ContactNotFoundError(id);
|
|
773
|
+
d.run(`UPDATE contacts SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
774
|
+
logActivity(d, { contact_id: id, action: "contact.archived", details: `Archived contact: ${row.display_name}` });
|
|
775
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
776
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
777
|
+
}
|
|
778
|
+
function unarchiveContact(id, db) {
|
|
779
|
+
const d = db || getDatabase();
|
|
780
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
781
|
+
if (!row)
|
|
782
|
+
throw new ContactNotFoundError(id);
|
|
783
|
+
d.run(`UPDATE contacts SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
784
|
+
logActivity(d, { contact_id: id, action: "contact.unarchived", details: `Unarchived contact: ${row.display_name}` });
|
|
785
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
786
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
787
|
+
}
|
|
788
|
+
function autoLinkContactToCompany(contactId, db) {
|
|
789
|
+
const d = db || getDatabase();
|
|
790
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
791
|
+
if (!row || row.company_id)
|
|
792
|
+
return null;
|
|
793
|
+
const emailRow = d.query(`SELECT address FROM emails WHERE contact_id = ? AND contact_id IS NOT NULL LIMIT 1`).get(contactId);
|
|
794
|
+
if (!emailRow)
|
|
795
|
+
return null;
|
|
796
|
+
const domain = emailRow.address.split("@")[1];
|
|
797
|
+
if (!domain)
|
|
798
|
+
return null;
|
|
799
|
+
const companyRow = d.query(`SELECT id FROM companies WHERE domain = ? LIMIT 1`).get(domain);
|
|
800
|
+
if (!companyRow)
|
|
801
|
+
return null;
|
|
802
|
+
d.run(`UPDATE contacts SET company_id = ?, updated_at = ? WHERE id = ?`, [companyRow.id, now(), contactId]);
|
|
803
|
+
logActivity(d, { contact_id: contactId, action: "contact.auto_linked", details: `Auto-linked to company via email domain: ${domain}` });
|
|
804
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
805
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
806
|
+
}
|
|
606
807
|
|
|
607
808
|
// src/db/groups.ts
|
|
608
809
|
function createGroup(db, input) {
|
|
@@ -614,7 +815,10 @@ function getGroup(db, id) {
|
|
|
614
815
|
return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
|
|
615
816
|
}
|
|
616
817
|
function listGroups(db) {
|
|
617
|
-
return db.query(`SELECT g.*,
|
|
818
|
+
return db.query(`SELECT g.*,
|
|
819
|
+
(SELECT COUNT(*) FROM contact_groups cg WHERE cg.group_id = g.id) as member_count,
|
|
820
|
+
(SELECT COUNT(*) FROM company_groups cog WHERE cog.group_id = g.id) as company_count
|
|
821
|
+
FROM groups g ORDER BY g.name`).all();
|
|
618
822
|
}
|
|
619
823
|
function updateGroup(db, id, input) {
|
|
620
824
|
const fields = [];
|
|
@@ -637,7 +841,11 @@ function deleteGroup(db, id) {
|
|
|
637
841
|
db.query(`DELETE FROM groups WHERE id = ?`).run(id);
|
|
638
842
|
}
|
|
639
843
|
function addContactToGroup(db, contactId, groupId) {
|
|
640
|
-
db.query(`
|
|
844
|
+
const existing = db.query(`SELECT 1 FROM contact_groups WHERE contact_id = ? AND group_id = ?`).get(contactId, groupId);
|
|
845
|
+
if (existing)
|
|
846
|
+
return { added: false, already_member: true };
|
|
847
|
+
db.query(`INSERT INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
|
|
848
|
+
return { added: true, already_member: false };
|
|
641
849
|
}
|
|
642
850
|
function removeContactFromGroup(db, contactId, groupId) {
|
|
643
851
|
db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
|
|
@@ -649,6 +857,23 @@ function listContactsInGroup(db, groupId) {
|
|
|
649
857
|
function listGroupsForContact(db, contactId) {
|
|
650
858
|
return db.query(`SELECT g.* FROM groups g JOIN contact_groups cg ON g.id = cg.group_id WHERE cg.contact_id = ? ORDER BY g.name`).all(contactId);
|
|
651
859
|
}
|
|
860
|
+
function addCompanyToGroup(db, companyId, groupId) {
|
|
861
|
+
const existing = db.query(`SELECT 1 FROM company_groups WHERE company_id = ? AND group_id = ?`).get(companyId, groupId);
|
|
862
|
+
if (existing)
|
|
863
|
+
return { added: false, already_member: true };
|
|
864
|
+
db.query(`INSERT INTO company_groups(company_id, group_id) VALUES(?,?)`).run(companyId, groupId);
|
|
865
|
+
return { added: true, already_member: false };
|
|
866
|
+
}
|
|
867
|
+
function removeCompanyFromGroup(db, companyId, groupId) {
|
|
868
|
+
db.query(`DELETE FROM company_groups WHERE company_id = ? AND group_id = ?`).run(companyId, groupId);
|
|
869
|
+
}
|
|
870
|
+
function listCompaniesInGroup(db, groupId) {
|
|
871
|
+
const rows = db.query(`SELECT company_id FROM company_groups WHERE group_id = ?`).all(groupId);
|
|
872
|
+
return rows.map((r) => r.company_id);
|
|
873
|
+
}
|
|
874
|
+
function listGroupsForCompany(db, companyId) {
|
|
875
|
+
return db.query(`SELECT g.* FROM groups g JOIN company_groups cog ON g.id = cog.group_id WHERE cog.company_id = ? ORDER BY g.name`).all(companyId);
|
|
876
|
+
}
|
|
652
877
|
|
|
653
878
|
// src/db/tags.ts
|
|
654
879
|
function rowToTag2(row) {
|
|
@@ -693,12 +918,28 @@ function removeTagFromContact(contactId, tagId, db) {
|
|
|
693
918
|
const d = db || getDatabase();
|
|
694
919
|
d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
|
|
695
920
|
}
|
|
921
|
+
function addTagToCompany(companyId, tagId, db) {
|
|
922
|
+
const d = db || getDatabase();
|
|
923
|
+
const company = d.query(`SELECT id FROM companies WHERE id = ?`).get(companyId);
|
|
924
|
+
if (!company)
|
|
925
|
+
throw new CompanyNotFoundError(companyId);
|
|
926
|
+
const tag = d.query(`SELECT id FROM tags WHERE id = ?`).get(tagId);
|
|
927
|
+
if (!tag)
|
|
928
|
+
throw new TagNotFoundError(tagId);
|
|
929
|
+
d.run(`INSERT OR IGNORE INTO company_tags (company_id, tag_id) VALUES (?, ?)`, [companyId, tagId]);
|
|
930
|
+
}
|
|
931
|
+
function removeTagFromCompany(companyId, tagId, db) {
|
|
932
|
+
const d = db || getDatabase();
|
|
933
|
+
d.run(`DELETE FROM company_tags WHERE company_id = ? AND tag_id = ?`, [companyId, tagId]);
|
|
934
|
+
}
|
|
696
935
|
|
|
697
936
|
// src/db/companies.ts
|
|
698
937
|
function rowToCompany2(row) {
|
|
699
938
|
return {
|
|
700
939
|
...row,
|
|
701
|
-
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
940
|
+
custom_fields: JSON.parse(row.custom_fields || "{}"),
|
|
941
|
+
archived: !!row.archived,
|
|
942
|
+
project_id: row.project_id ?? null
|
|
702
943
|
};
|
|
703
944
|
}
|
|
704
945
|
function insertEmails2(db, companyId, emails) {
|
|
@@ -808,15 +1049,23 @@ function listCompanies(opts = {}, db) {
|
|
|
808
1049
|
offset = 0,
|
|
809
1050
|
industry,
|
|
810
1051
|
tag_id,
|
|
1052
|
+
project_id,
|
|
1053
|
+
archived = false,
|
|
811
1054
|
order_by = "name",
|
|
812
1055
|
order_dir = "asc"
|
|
813
1056
|
} = opts;
|
|
814
1057
|
const conditions = [];
|
|
815
1058
|
const params = [];
|
|
1059
|
+
conditions.push("co.archived = ?");
|
|
1060
|
+
params.push(archived ? 1 : 0);
|
|
816
1061
|
if (industry) {
|
|
817
1062
|
conditions.push("co.industry = ?");
|
|
818
1063
|
params.push(industry);
|
|
819
1064
|
}
|
|
1065
|
+
if (project_id) {
|
|
1066
|
+
conditions.push("co.project_id = ?");
|
|
1067
|
+
params.push(project_id);
|
|
1068
|
+
}
|
|
820
1069
|
if (tag_id) {
|
|
821
1070
|
conditions.push("EXISTS (SELECT 1 FROM company_tags ct WHERE ct.company_id = co.id AND ct.tag_id = ?)");
|
|
822
1071
|
params.push(tag_id);
|
|
@@ -872,6 +1121,10 @@ function updateCompany(id, input, db) {
|
|
|
872
1121
|
setClauses.push("custom_fields = ?");
|
|
873
1122
|
params.push(JSON.stringify(input.custom_fields));
|
|
874
1123
|
}
|
|
1124
|
+
if ("project_id" in input && input.project_id !== undefined) {
|
|
1125
|
+
setClauses.push("project_id = ?");
|
|
1126
|
+
params.push(input.project_id);
|
|
1127
|
+
}
|
|
875
1128
|
params.push(id);
|
|
876
1129
|
d.run(`UPDATE companies SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
877
1130
|
logActivity(d, { company_id: id, action: "company.updated", details: `Updated company: ${existing.name}` });
|
|
@@ -895,6 +1148,26 @@ function searchCompanies(query, db) {
|
|
|
895
1148
|
`).all(`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`);
|
|
896
1149
|
return rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
|
|
897
1150
|
}
|
|
1151
|
+
function archiveCompany(id, db) {
|
|
1152
|
+
const d = db || getDatabase();
|
|
1153
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1154
|
+
if (!row)
|
|
1155
|
+
throw new CompanyNotFoundError(id);
|
|
1156
|
+
d.run(`UPDATE companies SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
1157
|
+
logActivity(d, { company_id: id, action: "company.archived", details: `Archived company: ${row.name}` });
|
|
1158
|
+
const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1159
|
+
return loadCompanyDetails(d, rowToCompany2(updated));
|
|
1160
|
+
}
|
|
1161
|
+
function unarchiveCompany(id, db) {
|
|
1162
|
+
const d = db || getDatabase();
|
|
1163
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1164
|
+
if (!row)
|
|
1165
|
+
throw new CompanyNotFoundError(id);
|
|
1166
|
+
d.run(`UPDATE companies SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
1167
|
+
logActivity(d, { company_id: id, action: "company.unarchived", details: `Unarchived company: ${row.name}` });
|
|
1168
|
+
const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1169
|
+
return loadCompanyDetails(d, rowToCompany2(updated));
|
|
1170
|
+
}
|
|
898
1171
|
|
|
899
1172
|
// src/db/relationships.ts
|
|
900
1173
|
function rowToRelationship(row) {
|
|
@@ -937,6 +1210,39 @@ function deleteRelationship(id, db) {
|
|
|
937
1210
|
d.run(`DELETE FROM contact_relationships WHERE id = ?`, [id]);
|
|
938
1211
|
}
|
|
939
1212
|
|
|
1213
|
+
// src/lib/dedup.ts
|
|
1214
|
+
function findEmailDuplicates(db) {
|
|
1215
|
+
const rows = db.query(`
|
|
1216
|
+
SELECT e.address as email, GROUP_CONCAT(e.contact_id) as ids
|
|
1217
|
+
FROM emails e
|
|
1218
|
+
WHERE e.contact_id IS NOT NULL
|
|
1219
|
+
GROUP BY LOWER(e.address)
|
|
1220
|
+
HAVING COUNT(*) > 1
|
|
1221
|
+
`).all();
|
|
1222
|
+
return rows.map((r) => ({ email: r.email, contact_ids: r.ids.split(",") }));
|
|
1223
|
+
}
|
|
1224
|
+
function levenshtein(a, b) {
|
|
1225
|
+
const m = a.length, n = b.length;
|
|
1226
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
|
|
1227
|
+
for (let i = 1;i <= m; i++)
|
|
1228
|
+
for (let j = 1;j <= n; j++)
|
|
1229
|
+
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]);
|
|
1230
|
+
return dp[m][n];
|
|
1231
|
+
}
|
|
1232
|
+
function findNameDuplicates(db) {
|
|
1233
|
+
const contacts = db.query(`SELECT id, display_name FROM contacts`).all();
|
|
1234
|
+
const pairs = [];
|
|
1235
|
+
for (let i = 0;i < contacts.length; i++) {
|
|
1236
|
+
for (let j = i + 1;j < contacts.length; j++) {
|
|
1237
|
+
const dist = levenshtein(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
|
|
1238
|
+
if (dist <= 2 && dist > 0) {
|
|
1239
|
+
pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return pairs;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
940
1246
|
// src/lib/import.ts
|
|
941
1247
|
function parseCsv(text) {
|
|
942
1248
|
const lines = text.split(/\r?\n/);
|
|
@@ -1403,6 +1709,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1403
1709
|
website: { type: "string", description: "Personal or professional website URL" },
|
|
1404
1710
|
last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
|
|
1405
1711
|
preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
|
|
1712
|
+
status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"], description: "Contact lifecycle status (default: active)" },
|
|
1713
|
+
follow_up_at: { type: "string", description: "ISO 8601 datetime to follow up with this contact" },
|
|
1714
|
+
project_id: { type: "string", description: "Associate contact with a project ID" },
|
|
1406
1715
|
emails: {
|
|
1407
1716
|
type: "array",
|
|
1408
1717
|
items: {
|
|
@@ -1471,7 +1780,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1471
1780
|
},
|
|
1472
1781
|
{
|
|
1473
1782
|
name: "update_contact",
|
|
1474
|
-
description: "Update an existing contact's fields. Only provided fields are changed
|
|
1783
|
+
description: "Update an existing contact's fields. Only provided fields are changed. Supports all contact fields including status, follow_up_at, project_id. Use emails_add/phones_add to append new contact methods without replacing existing ones.",
|
|
1475
1784
|
inputSchema: {
|
|
1476
1785
|
type: "object",
|
|
1477
1786
|
properties: {
|
|
@@ -1487,7 +1796,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1487
1796
|
website: { type: "string" },
|
|
1488
1797
|
last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
|
|
1489
1798
|
preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
|
|
1490
|
-
|
|
1799
|
+
status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
|
|
1800
|
+
follow_up_at: { type: "string", description: "ISO 8601 datetime for follow-up reminder (null to clear)" },
|
|
1801
|
+
project_id: { type: "string", description: "Project ID to associate this contact with (null to clear)" },
|
|
1802
|
+
source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
|
|
1803
|
+
emails_add: { type: "array", items: { type: "object", properties: { address: { type: "string" }, type: { type: "string" }, is_primary: { type: "boolean" } }, required: ["address"] }, description: "New email addresses to append (duplicates are skipped)" },
|
|
1804
|
+
phones_add: { type: "array", items: { type: "object", properties: { number: { type: "string" }, type: { type: "string" }, country_code: { type: "string" }, is_primary: { type: "boolean" } }, required: ["number"] }, description: "New phone numbers to append (duplicates are skipped)" }
|
|
1491
1805
|
},
|
|
1492
1806
|
required: ["id"]
|
|
1493
1807
|
}
|
|
@@ -1503,15 +1817,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1503
1817
|
},
|
|
1504
1818
|
{
|
|
1505
1819
|
name: "list_contacts",
|
|
1506
|
-
description: "List contacts with optional filters. Supports filtering by company, tag,
|
|
1820
|
+
description: "List contacts with optional filters. Supports filtering by company, tag(s), source, status, project, follow-up due, last_contacted date range, and archived state. Returns paginated results with total count.",
|
|
1507
1821
|
inputSchema: {
|
|
1508
1822
|
type: "object",
|
|
1509
1823
|
properties: {
|
|
1510
1824
|
company_id: { type: "string" },
|
|
1511
|
-
tag_id: { type: "string", description: "Filter by tag ID" },
|
|
1825
|
+
tag_id: { type: "string", description: "Filter by a single tag ID" },
|
|
1826
|
+
tag_ids: { type: "array", items: { type: "string" }, description: "Filter by multiple tag IDs (AND logic \u2014 contact must have all tags)" },
|
|
1827
|
+
source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
|
|
1828
|
+
status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
|
|
1829
|
+
project_id: { type: "string", description: "Filter by project ID" },
|
|
1830
|
+
archived: { type: "boolean", description: "Include archived contacts (default false)" },
|
|
1831
|
+
follow_up_due: { type: "boolean", description: "Only return contacts whose follow_up_at is in the past" },
|
|
1832
|
+
last_contacted_after: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted after this date" },
|
|
1833
|
+
last_contacted_before: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted before this date" },
|
|
1512
1834
|
limit: { type: "number", description: "Max results (default 50)" },
|
|
1513
1835
|
offset: { type: "number" },
|
|
1514
|
-
order_by: { type: "string", enum: ["display_name", "created_at", "updated_at"] },
|
|
1836
|
+
order_by: { type: "string", enum: ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"] },
|
|
1515
1837
|
order_dir: { type: "string", enum: ["asc", "desc"] }
|
|
1516
1838
|
}
|
|
1517
1839
|
}
|
|
@@ -1585,12 +1907,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1585
1907
|
},
|
|
1586
1908
|
{
|
|
1587
1909
|
name: "list_companies",
|
|
1588
|
-
description: "List companies with optional filters by industry or
|
|
1910
|
+
description: "List companies with optional filters by industry, tag, project, or archived state. Returns paginated results with total count.",
|
|
1589
1911
|
inputSchema: {
|
|
1590
1912
|
type: "object",
|
|
1591
1913
|
properties: {
|
|
1592
1914
|
tag_id: { type: "string" },
|
|
1593
1915
|
industry: { type: "string" },
|
|
1916
|
+
project_id: { type: "string", description: "Filter by project ID" },
|
|
1917
|
+
archived: { type: "boolean", description: "Include archived companies (default false)" },
|
|
1594
1918
|
limit: { type: "number" },
|
|
1595
1919
|
offset: { type: "number" }
|
|
1596
1920
|
}
|
|
@@ -1925,6 +2249,188 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1925
2249
|
properties: { contact_id: { type: "string" } },
|
|
1926
2250
|
required: ["contact_id"]
|
|
1927
2251
|
}
|
|
2252
|
+
},
|
|
2253
|
+
{
|
|
2254
|
+
name: "get_contact_by_email",
|
|
2255
|
+
description: "Fast lookup of a contact by exact email address. Returns null if not found. Unlike search_contacts, this is a precise read-only lookup with no side effects.",
|
|
2256
|
+
inputSchema: {
|
|
2257
|
+
type: "object",
|
|
2258
|
+
properties: { email: { type: "string", description: "Exact email address to look up" } },
|
|
2259
|
+
required: ["email"]
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
2262
|
+
{
|
|
2263
|
+
name: "add_email_to_contact",
|
|
2264
|
+
description: "Append a new email address to a contact. Idempotent \u2014 silently skips if the email already exists on this contact.",
|
|
2265
|
+
inputSchema: {
|
|
2266
|
+
type: "object",
|
|
2267
|
+
properties: {
|
|
2268
|
+
contact_id: { type: "string" },
|
|
2269
|
+
address: { type: "string" },
|
|
2270
|
+
type: { type: "string", enum: ["work", "personal", "other"] },
|
|
2271
|
+
is_primary: { type: "boolean" }
|
|
2272
|
+
},
|
|
2273
|
+
required: ["contact_id", "address"]
|
|
2274
|
+
}
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
name: "add_phone_to_contact",
|
|
2278
|
+
description: "Append a new phone number to a contact. Idempotent \u2014 silently skips if the number already exists on this contact.",
|
|
2279
|
+
inputSchema: {
|
|
2280
|
+
type: "object",
|
|
2281
|
+
properties: {
|
|
2282
|
+
contact_id: { type: "string" },
|
|
2283
|
+
number: { type: "string" },
|
|
2284
|
+
type: { type: "string", enum: ["mobile", "work", "home", "fax", "whatsapp", "other"] },
|
|
2285
|
+
country_code: { type: "string" },
|
|
2286
|
+
is_primary: { type: "boolean" }
|
|
2287
|
+
},
|
|
2288
|
+
required: ["contact_id", "number"]
|
|
2289
|
+
}
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
name: "archive_contact",
|
|
2293
|
+
description: "Soft-delete a contact by setting archived=true. Archived contacts are excluded from list_contacts and search_contacts by default. Use unarchive_contact to restore.",
|
|
2294
|
+
inputSchema: {
|
|
2295
|
+
type: "object",
|
|
2296
|
+
properties: { id: { type: "string" } },
|
|
2297
|
+
required: ["id"]
|
|
2298
|
+
}
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
name: "unarchive_contact",
|
|
2302
|
+
description: "Restore an archived contact. Sets archived=false so the contact reappears in lists and searches.",
|
|
2303
|
+
inputSchema: {
|
|
2304
|
+
type: "object",
|
|
2305
|
+
properties: { id: { type: "string" } },
|
|
2306
|
+
required: ["id"]
|
|
2307
|
+
}
|
|
2308
|
+
},
|
|
2309
|
+
{
|
|
2310
|
+
name: "archive_company",
|
|
2311
|
+
description: "Soft-delete a company by setting archived=true. Archived companies are excluded from list_companies by default.",
|
|
2312
|
+
inputSchema: {
|
|
2313
|
+
type: "object",
|
|
2314
|
+
properties: { id: { type: "string" } },
|
|
2315
|
+
required: ["id"]
|
|
2316
|
+
}
|
|
2317
|
+
},
|
|
2318
|
+
{
|
|
2319
|
+
name: "unarchive_company",
|
|
2320
|
+
description: "Restore an archived company. Sets archived=false.",
|
|
2321
|
+
inputSchema: {
|
|
2322
|
+
type: "object",
|
|
2323
|
+
properties: { id: { type: "string" } },
|
|
2324
|
+
required: ["id"]
|
|
2325
|
+
}
|
|
2326
|
+
},
|
|
2327
|
+
{
|
|
2328
|
+
name: "find_duplicates",
|
|
2329
|
+
description: "Scan contacts for potential duplicates \u2014 by shared email address (exact) or by similar display name (Levenshtein distance \u2264 2). Returns groups of contact IDs that may be the same person.",
|
|
2330
|
+
inputSchema: { type: "object", properties: {} }
|
|
2331
|
+
},
|
|
2332
|
+
{
|
|
2333
|
+
name: "list_interactions",
|
|
2334
|
+
description: "List activity log entries for a contact or company. Shows all logged interactions, creates, updates, and merges in reverse chronological order.",
|
|
2335
|
+
inputSchema: {
|
|
2336
|
+
type: "object",
|
|
2337
|
+
properties: {
|
|
2338
|
+
contact_id: { type: "string" },
|
|
2339
|
+
company_id: { type: "string" },
|
|
2340
|
+
limit: { type: "number", description: "Max results (default 50)" },
|
|
2341
|
+
offset: { type: "number" }
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
},
|
|
2345
|
+
{
|
|
2346
|
+
name: "add_tag_to_company",
|
|
2347
|
+
description: "Apply an existing tag to a company. Use list_tags to find tag IDs.",
|
|
2348
|
+
inputSchema: {
|
|
2349
|
+
type: "object",
|
|
2350
|
+
properties: {
|
|
2351
|
+
company_id: { type: "string" },
|
|
2352
|
+
tag_id: { type: "string" }
|
|
2353
|
+
},
|
|
2354
|
+
required: ["company_id", "tag_id"]
|
|
2355
|
+
}
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
name: "remove_tag_from_company",
|
|
2359
|
+
description: "Remove a tag from a company. The tag itself is not deleted.",
|
|
2360
|
+
inputSchema: {
|
|
2361
|
+
type: "object",
|
|
2362
|
+
properties: {
|
|
2363
|
+
company_id: { type: "string" },
|
|
2364
|
+
tag_id: { type: "string" }
|
|
2365
|
+
},
|
|
2366
|
+
required: ["company_id", "tag_id"]
|
|
2367
|
+
}
|
|
2368
|
+
},
|
|
2369
|
+
{
|
|
2370
|
+
name: "add_company_to_group",
|
|
2371
|
+
description: "Add a company to a group. Returns { added, already_member } \u2014 idempotent.",
|
|
2372
|
+
inputSchema: {
|
|
2373
|
+
type: "object",
|
|
2374
|
+
properties: {
|
|
2375
|
+
company_id: { type: "string" },
|
|
2376
|
+
group_id: { type: "string" }
|
|
2377
|
+
},
|
|
2378
|
+
required: ["company_id", "group_id"]
|
|
2379
|
+
}
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
name: "remove_company_from_group",
|
|
2383
|
+
description: "Remove a company from a group.",
|
|
2384
|
+
inputSchema: {
|
|
2385
|
+
type: "object",
|
|
2386
|
+
properties: {
|
|
2387
|
+
company_id: { type: "string" },
|
|
2388
|
+
group_id: { type: "string" }
|
|
2389
|
+
},
|
|
2390
|
+
required: ["company_id", "group_id"]
|
|
2391
|
+
}
|
|
2392
|
+
},
|
|
2393
|
+
{
|
|
2394
|
+
name: "list_companies_in_group",
|
|
2395
|
+
description: "List all company IDs in a group.",
|
|
2396
|
+
inputSchema: {
|
|
2397
|
+
type: "object",
|
|
2398
|
+
properties: { group_id: { type: "string" } },
|
|
2399
|
+
required: ["group_id"]
|
|
2400
|
+
}
|
|
2401
|
+
},
|
|
2402
|
+
{
|
|
2403
|
+
name: "list_groups_for_company",
|
|
2404
|
+
description: "List all groups that a company belongs to.",
|
|
2405
|
+
inputSchema: {
|
|
2406
|
+
type: "object",
|
|
2407
|
+
properties: { company_id: { type: "string" } },
|
|
2408
|
+
required: ["company_id"]
|
|
2409
|
+
}
|
|
2410
|
+
},
|
|
2411
|
+
{
|
|
2412
|
+
name: "bulk_create_contacts",
|
|
2413
|
+
description: "Create multiple contacts in one call. Each item in the contacts array follows the same schema as create_contact. Returns { created, errors }.",
|
|
2414
|
+
inputSchema: {
|
|
2415
|
+
type: "object",
|
|
2416
|
+
properties: {
|
|
2417
|
+
contacts: {
|
|
2418
|
+
type: "array",
|
|
2419
|
+
items: { type: "object" },
|
|
2420
|
+
description: "Array of contact input objects (same schema as create_contact)"
|
|
2421
|
+
}
|
|
2422
|
+
},
|
|
2423
|
+
required: ["contacts"]
|
|
2424
|
+
}
|
|
2425
|
+
},
|
|
2426
|
+
{
|
|
2427
|
+
name: "auto_link_to_company",
|
|
2428
|
+
description: "Auto-link a contact to a company by matching the contact's email domain against known company domains. Only sets company_id if the contact has no company yet and a matching company exists. Returns the updated contact, or null if no match.",
|
|
2429
|
+
inputSchema: {
|
|
2430
|
+
type: "object",
|
|
2431
|
+
properties: { contact_id: { type: "string" } },
|
|
2432
|
+
required: ["contact_id"]
|
|
2433
|
+
}
|
|
1928
2434
|
}
|
|
1929
2435
|
]
|
|
1930
2436
|
}));
|
|
@@ -1946,6 +2452,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1946
2452
|
website: a.website,
|
|
1947
2453
|
last_contacted_at: a.last_contacted_at,
|
|
1948
2454
|
preferred_contact_method: a.preferred_contact_method,
|
|
2455
|
+
status: a.status,
|
|
2456
|
+
follow_up_at: a.follow_up_at,
|
|
2457
|
+
project_id: a.project_id,
|
|
1949
2458
|
emails: a.emails,
|
|
1950
2459
|
phones: a.phones,
|
|
1951
2460
|
addresses: a.addresses,
|
|
@@ -1974,7 +2483,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1974
2483
|
website: rest.website,
|
|
1975
2484
|
last_contacted_at: rest.last_contacted_at,
|
|
1976
2485
|
preferred_contact_method: rest.preferred_contact_method,
|
|
1977
|
-
|
|
2486
|
+
status: rest.status,
|
|
2487
|
+
follow_up_at: rest.follow_up_at,
|
|
2488
|
+
project_id: rest.project_id,
|
|
2489
|
+
source: rest.source,
|
|
2490
|
+
emails_add: rest.emails_add,
|
|
2491
|
+
phones_add: rest.phones_add
|
|
1978
2492
|
};
|
|
1979
2493
|
const contact = updateContact(id, input);
|
|
1980
2494
|
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
@@ -1987,6 +2501,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1987
2501
|
const result = listContacts({
|
|
1988
2502
|
company_id: a.company_id,
|
|
1989
2503
|
tag_id: a.tag_id,
|
|
2504
|
+
tag_ids: a.tag_ids,
|
|
2505
|
+
source: a.source,
|
|
2506
|
+
status: a.status,
|
|
2507
|
+
project_id: a.project_id,
|
|
2508
|
+
archived: a.archived,
|
|
2509
|
+
follow_up_due: a.follow_up_due,
|
|
2510
|
+
last_contacted_after: a.last_contacted_after,
|
|
2511
|
+
last_contacted_before: a.last_contacted_before,
|
|
1990
2512
|
limit: a.limit,
|
|
1991
2513
|
offset: a.offset,
|
|
1992
2514
|
order_by: a.order_by,
|
|
@@ -1999,6 +2521,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1999
2521
|
return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }] };
|
|
2000
2522
|
}
|
|
2001
2523
|
case "create_company": {
|
|
2524
|
+
const rawTagIds = a.tag_ids;
|
|
2525
|
+
const tagIds = typeof rawTagIds === "string" ? JSON.parse(rawTagIds) : rawTagIds;
|
|
2002
2526
|
const input = {
|
|
2003
2527
|
name: a.name,
|
|
2004
2528
|
domain: a.domain,
|
|
@@ -2011,7 +2535,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2011
2535
|
phones: a.phones,
|
|
2012
2536
|
addresses: a.addresses,
|
|
2013
2537
|
social_profiles: a.social_profiles,
|
|
2014
|
-
tag_ids:
|
|
2538
|
+
tag_ids: tagIds
|
|
2015
2539
|
};
|
|
2016
2540
|
const company = createCompany(input);
|
|
2017
2541
|
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
@@ -2045,6 +2569,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2045
2569
|
const result = listCompanies({
|
|
2046
2570
|
tag_id: a.tag_id,
|
|
2047
2571
|
industry: a.industry,
|
|
2572
|
+
project_id: a.project_id,
|
|
2573
|
+
archived: a.archived,
|
|
2048
2574
|
limit: a.limit,
|
|
2049
2575
|
offset: a.offset
|
|
2050
2576
|
});
|
|
@@ -2168,21 +2694,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2168
2694
|
const emailAddresses = a.emails?.map((e) => e.address) ?? [];
|
|
2169
2695
|
let found = null;
|
|
2170
2696
|
for (const addr of emailAddresses) {
|
|
2171
|
-
const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE address = ? AND contact_id IS NOT NULL LIMIT 1`).get(addr);
|
|
2697
|
+
const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(addr);
|
|
2172
2698
|
if (emailRow) {
|
|
2173
2699
|
found = getContact(emailRow.contact_id);
|
|
2174
2700
|
break;
|
|
2175
2701
|
}
|
|
2176
2702
|
}
|
|
2177
|
-
if (!found
|
|
2178
|
-
const
|
|
2179
|
-
if (
|
|
2180
|
-
|
|
2703
|
+
if (!found) {
|
|
2704
|
+
const nameQuery = a.display_name ?? (a.first_name || a.last_name ? `${a.first_name ?? ""} ${a.last_name ?? ""}`.trim() : null);
|
|
2705
|
+
if (nameQuery) {
|
|
2706
|
+
const results = searchContacts(nameQuery);
|
|
2707
|
+
if (results.length > 0)
|
|
2708
|
+
found = results[0];
|
|
2709
|
+
}
|
|
2181
2710
|
}
|
|
2182
2711
|
if (found) {
|
|
2183
2712
|
return { content: [{ type: "text", text: JSON.stringify({ contact: found, found: true, created: false }, null, 2) }] };
|
|
2184
2713
|
}
|
|
2185
|
-
const
|
|
2714
|
+
const focInput = {
|
|
2186
2715
|
first_name: a.first_name,
|
|
2187
2716
|
last_name: a.last_name,
|
|
2188
2717
|
display_name: a.display_name,
|
|
@@ -2194,6 +2723,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2194
2723
|
website: a.website,
|
|
2195
2724
|
last_contacted_at: a.last_contacted_at,
|
|
2196
2725
|
preferred_contact_method: a.preferred_contact_method,
|
|
2726
|
+
status: a.status,
|
|
2727
|
+
follow_up_at: a.follow_up_at,
|
|
2728
|
+
project_id: a.project_id,
|
|
2197
2729
|
emails: a.emails,
|
|
2198
2730
|
phones: a.phones,
|
|
2199
2731
|
addresses: a.addresses,
|
|
@@ -2201,7 +2733,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2201
2733
|
tag_ids: a.tag_ids,
|
|
2202
2734
|
source: a.source
|
|
2203
2735
|
};
|
|
2204
|
-
const contact = createContact(
|
|
2736
|
+
const contact = createContact(focInput);
|
|
2205
2737
|
return { content: [{ type: "text", text: JSON.stringify({ contact, found: false, created: true }, null, 2) }] };
|
|
2206
2738
|
}
|
|
2207
2739
|
case "upsert_contact": {
|
|
@@ -2325,8 +2857,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2325
2857
|
}
|
|
2326
2858
|
case "add_contact_to_group": {
|
|
2327
2859
|
const db = getDatabase();
|
|
2328
|
-
addContactToGroup(db, a.contact_id, a.group_id);
|
|
2329
|
-
return { content: [{ type: "text", text:
|
|
2860
|
+
const result = addContactToGroup(db, a.contact_id, a.group_id);
|
|
2861
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2330
2862
|
}
|
|
2331
2863
|
case "remove_contact_from_group": {
|
|
2332
2864
|
const db = getDatabase();
|
|
@@ -2343,6 +2875,108 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2343
2875
|
const groups = listGroupsForContact(db, a.contact_id);
|
|
2344
2876
|
return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
|
|
2345
2877
|
}
|
|
2878
|
+
case "get_contact_by_email": {
|
|
2879
|
+
const contact = getContactByEmail(a.email);
|
|
2880
|
+
if (!contact)
|
|
2881
|
+
return { content: [{ type: "text", text: "null" }] };
|
|
2882
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2883
|
+
}
|
|
2884
|
+
case "add_email_to_contact": {
|
|
2885
|
+
const contact = addEmailToContact(a.contact_id, {
|
|
2886
|
+
address: a.address,
|
|
2887
|
+
type: a.type,
|
|
2888
|
+
is_primary: a.is_primary
|
|
2889
|
+
});
|
|
2890
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2891
|
+
}
|
|
2892
|
+
case "add_phone_to_contact": {
|
|
2893
|
+
const contact = addPhoneToContact(a.contact_id, {
|
|
2894
|
+
number: a.number,
|
|
2895
|
+
type: a.type,
|
|
2896
|
+
country_code: a.country_code,
|
|
2897
|
+
is_primary: a.is_primary
|
|
2898
|
+
});
|
|
2899
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2900
|
+
}
|
|
2901
|
+
case "archive_contact": {
|
|
2902
|
+
const contact = archiveContact(a.id);
|
|
2903
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2904
|
+
}
|
|
2905
|
+
case "unarchive_contact": {
|
|
2906
|
+
const contact = unarchiveContact(a.id);
|
|
2907
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2908
|
+
}
|
|
2909
|
+
case "archive_company": {
|
|
2910
|
+
const company = archiveCompany(a.id);
|
|
2911
|
+
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
2912
|
+
}
|
|
2913
|
+
case "unarchive_company": {
|
|
2914
|
+
const company = unarchiveCompany(a.id);
|
|
2915
|
+
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
2916
|
+
}
|
|
2917
|
+
case "find_duplicates": {
|
|
2918
|
+
const db = getDatabase();
|
|
2919
|
+
const byEmail = findEmailDuplicates(db);
|
|
2920
|
+
const byName = findNameDuplicates(db);
|
|
2921
|
+
return { content: [{ type: "text", text: JSON.stringify({ by_email: byEmail, by_name: byName }, null, 2) }] };
|
|
2922
|
+
}
|
|
2923
|
+
case "list_interactions": {
|
|
2924
|
+
const result = listActivity({
|
|
2925
|
+
contact_id: a.contact_id,
|
|
2926
|
+
company_id: a.company_id,
|
|
2927
|
+
limit: a.limit,
|
|
2928
|
+
offset: a.offset
|
|
2929
|
+
});
|
|
2930
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2931
|
+
}
|
|
2932
|
+
case "add_tag_to_company": {
|
|
2933
|
+
addTagToCompany(a.company_id, a.tag_id);
|
|
2934
|
+
return { content: [{ type: "text", text: `Tag ${a.tag_id} added to company ${a.company_id}` }] };
|
|
2935
|
+
}
|
|
2936
|
+
case "remove_tag_from_company": {
|
|
2937
|
+
removeTagFromCompany(a.company_id, a.tag_id);
|
|
2938
|
+
return { content: [{ type: "text", text: `Tag ${a.tag_id} removed from company ${a.company_id}` }] };
|
|
2939
|
+
}
|
|
2940
|
+
case "add_company_to_group": {
|
|
2941
|
+
const db = getDatabase();
|
|
2942
|
+
const result = addCompanyToGroup(db, a.company_id, a.group_id);
|
|
2943
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2944
|
+
}
|
|
2945
|
+
case "remove_company_from_group": {
|
|
2946
|
+
const db = getDatabase();
|
|
2947
|
+
removeCompanyFromGroup(db, a.company_id, a.group_id);
|
|
2948
|
+
return { content: [{ type: "text", text: `Company ${a.company_id} removed from group ${a.group_id}` }] };
|
|
2949
|
+
}
|
|
2950
|
+
case "list_companies_in_group": {
|
|
2951
|
+
const db = getDatabase();
|
|
2952
|
+
const companyIds = listCompaniesInGroup(db, a.group_id);
|
|
2953
|
+
return { content: [{ type: "text", text: JSON.stringify(companyIds, null, 2) }] };
|
|
2954
|
+
}
|
|
2955
|
+
case "list_groups_for_company": {
|
|
2956
|
+
const db = getDatabase();
|
|
2957
|
+
const groups = listGroupsForCompany(db, a.company_id);
|
|
2958
|
+
return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
|
|
2959
|
+
}
|
|
2960
|
+
case "bulk_create_contacts": {
|
|
2961
|
+
const contacts = a.contacts;
|
|
2962
|
+
let created = 0;
|
|
2963
|
+
const errors = [];
|
|
2964
|
+
for (const item of contacts) {
|
|
2965
|
+
try {
|
|
2966
|
+
createContact(item);
|
|
2967
|
+
created++;
|
|
2968
|
+
} catch (err) {
|
|
2969
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
return { content: [{ type: "text", text: JSON.stringify({ created, errors: errors.length, error_details: errors }, null, 2) }] };
|
|
2973
|
+
}
|
|
2974
|
+
case "auto_link_to_company": {
|
|
2975
|
+
const contact = autoLinkContactToCompany(a.contact_id);
|
|
2976
|
+
if (!contact)
|
|
2977
|
+
return { content: [{ type: "text", text: "null" }] };
|
|
2978
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2979
|
+
}
|
|
2346
2980
|
default:
|
|
2347
2981
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
2348
2982
|
}
|