@hasna/contacts 0.6.22 → 0.6.23

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 (102) hide show
  1. package/README.md +24 -6
  2. package/dist/cli/commands/core.d.ts.map +1 -1
  3. package/dist/cli/index.js +1350 -269
  4. package/dist/cli/storage.d.ts.map +1 -1
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/remote-storage.d.ts +14 -0
  7. package/dist/db/remote-storage.d.ts.map +1 -0
  8. package/dist/db/remote-sync.d.ts +49 -0
  9. package/dist/db/remote-sync.d.ts.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1002 -7
  13. package/dist/mcp/index.js +1073 -60
  14. package/dist/mcp/storage-tools.d.ts.map +1 -1
  15. package/dist/server/index.js +1073 -60
  16. package/package.json +8 -4
  17. package/dist/cli/storage-contract.test.d.ts +0 -2
  18. package/dist/cli/storage-contract.test.d.ts.map +0 -1
  19. package/dist/db/activity.test.d.ts +0 -2
  20. package/dist/db/activity.test.d.ts.map +0 -1
  21. package/dist/db/applications.test.d.ts +0 -2
  22. package/dist/db/applications.test.d.ts.map +0 -1
  23. package/dist/db/companies.test.d.ts +0 -2
  24. package/dist/db/companies.test.d.ts.map +0 -1
  25. package/dist/db/contact-tasks.test.d.ts +0 -2
  26. package/dist/db/contact-tasks.test.d.ts.map +0 -1
  27. package/dist/db/contacts.test.d.ts +0 -2
  28. package/dist/db/contacts.test.d.ts.map +0 -1
  29. package/dist/db/coordination.test.d.ts +0 -2
  30. package/dist/db/coordination.test.d.ts.map +0 -1
  31. package/dist/db/database.test.d.ts +0 -2
  32. package/dist/db/database.test.d.ts.map +0 -1
  33. package/dist/db/deals.test.d.ts +0 -2
  34. package/dist/db/deals.test.d.ts.map +0 -1
  35. package/dist/db/documents.test.d.ts +0 -2
  36. package/dist/db/documents.test.d.ts.map +0 -1
  37. package/dist/db/events.test.d.ts +0 -2
  38. package/dist/db/events.test.d.ts.map +0 -1
  39. package/dist/db/field-history.test.d.ts +0 -2
  40. package/dist/db/field-history.test.d.ts.map +0 -1
  41. package/dist/db/freshness.test.d.ts +0 -2
  42. package/dist/db/freshness.test.d.ts.map +0 -1
  43. package/dist/db/graph.test.d.ts +0 -2
  44. package/dist/db/graph.test.d.ts.map +0 -1
  45. package/dist/db/groups.test.d.ts +0 -2
  46. package/dist/db/groups.test.d.ts.map +0 -1
  47. package/dist/db/health.test.d.ts +0 -2
  48. package/dist/db/health.test.d.ts.map +0 -1
  49. package/dist/db/identity.test.d.ts +0 -2
  50. package/dist/db/identity.test.d.ts.map +0 -1
  51. package/dist/db/job-history.test.d.ts +0 -2
  52. package/dist/db/job-history.test.d.ts.map +0 -1
  53. package/dist/db/learnings.test.d.ts +0 -2
  54. package/dist/db/learnings.test.d.ts.map +0 -1
  55. package/dist/db/notes.test.d.ts +0 -2
  56. package/dist/db/notes.test.d.ts.map +0 -1
  57. package/dist/db/org-chart.test.d.ts +0 -2
  58. package/dist/db/org-chart.test.d.ts.map +0 -1
  59. package/dist/db/org-members.test.d.ts +0 -2
  60. package/dist/db/org-members.test.d.ts.map +0 -1
  61. package/dist/db/relationships.test.d.ts +0 -2
  62. package/dist/db/relationships.test.d.ts.map +0 -1
  63. package/dist/db/signals.test.d.ts +0 -2
  64. package/dist/db/signals.test.d.ts.map +0 -1
  65. package/dist/db/sqlite-adapter.test.d.ts +0 -2
  66. package/dist/db/sqlite-adapter.test.d.ts.map +0 -1
  67. package/dist/db/storage.test.d.ts +0 -2
  68. package/dist/db/storage.test.d.ts.map +0 -1
  69. package/dist/db/tags.test.d.ts +0 -2
  70. package/dist/db/tags.test.d.ts.map +0 -1
  71. package/dist/db/vendor-comms.test.d.ts +0 -2
  72. package/dist/db/vendor-comms.test.d.ts.map +0 -1
  73. package/dist/lib/audit.test.d.ts +0 -2
  74. package/dist/lib/audit.test.d.ts.map +0 -1
  75. package/dist/lib/brief.test.d.ts +0 -2
  76. package/dist/lib/brief.test.d.ts.map +0 -1
  77. package/dist/lib/config.test.d.ts +0 -2
  78. package/dist/lib/config.test.d.ts.map +0 -1
  79. package/dist/lib/dedup.test.d.ts +0 -2
  80. package/dist/lib/dedup.test.d.ts.map +0 -1
  81. package/dist/lib/export.test.d.ts +0 -2
  82. package/dist/lib/export.test.d.ts.map +0 -1
  83. package/dist/lib/gmail-import.test.d.ts +0 -2
  84. package/dist/lib/gmail-import.test.d.ts.map +0 -1
  85. package/dist/lib/google-contacts.test.d.ts +0 -2
  86. package/dist/lib/google-contacts.test.d.ts.map +0 -1
  87. package/dist/lib/import.test.d.ts +0 -2
  88. package/dist/lib/import.test.d.ts.map +0 -1
  89. package/dist/lib/signature-parser.test.d.ts +0 -2
  90. package/dist/lib/signature-parser.test.d.ts.map +0 -1
  91. package/dist/lib/stats.test.d.ts +0 -2
  92. package/dist/lib/stats.test.d.ts.map +0 -1
  93. package/dist/lib/timeline.test.d.ts +0 -2
  94. package/dist/lib/timeline.test.d.ts.map +0 -1
  95. package/dist/lib/upcoming.test.d.ts +0 -2
  96. package/dist/lib/upcoming.test.d.ts.map +0 -1
  97. package/dist/mcp/http.test.d.ts +0 -2
  98. package/dist/mcp/http.test.d.ts.map +0 -1
  99. package/dist/mcp/mcp.test.d.ts +0 -2
  100. package/dist/mcp/mcp.test.d.ts.map +0 -1
  101. package/dist/mcp/storage-contract.test.d.ts +0 -2
  102. package/dist/mcp/storage-contract.test.d.ts.map +0 -1
package/dist/mcp/index.js CHANGED
@@ -58,22 +58,44 @@ class SqliteAdapter {
58
58
  var init_sqlite_adapter = () => {};
59
59
 
60
60
  // src/db/database.ts
61
- import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, statSync } from "fs";
61
+ import { chmodSync, copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, statSync } from "fs";
62
62
  import { dirname, join as join2, resolve } from "path";
63
+ function ensurePrivateDir(dir) {
64
+ if (!existsSync2(dir))
65
+ mkdirSync(dir, { recursive: true, mode: 448 });
66
+ chmodSync(dir, 448);
67
+ }
68
+ function ensureCreatedPrivateDir(dir) {
69
+ if (!existsSync2(dir))
70
+ mkdirSync(dir, { recursive: true, mode: 448 });
71
+ }
72
+ function protectDatabaseArtifacts(dbPath) {
73
+ if (dbPath === ":memory:")
74
+ return;
75
+ for (const artifact of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
76
+ if (existsSync2(artifact))
77
+ chmodSync(artifact, 384);
78
+ }
79
+ }
63
80
  function getDataDir() {
64
81
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
82
+ const hasnaDir = join2(home, ".hasna");
65
83
  const newDir = join2(home, ".hasna", "contacts");
66
84
  const oldDir = join2(home, ".contacts");
67
85
  if (existsSync2(oldDir) && !existsSync2(newDir)) {
68
- mkdirSync(newDir, { recursive: true });
86
+ ensurePrivateDir(hasnaDir);
87
+ ensurePrivateDir(newDir);
69
88
  for (const file of readdirSync(oldDir)) {
70
89
  const oldPath = join2(oldDir, file);
71
90
  if (statSync(oldPath).isFile()) {
72
- copyFileSync(oldPath, join2(newDir, file));
91
+ const newPath = join2(newDir, file);
92
+ copyFileSync(oldPath, newPath);
93
+ chmodSync(newPath, 384);
73
94
  }
74
95
  }
75
96
  }
76
- mkdirSync(newDir, { recursive: true });
97
+ ensurePrivateDir(hasnaDir);
98
+ ensurePrivateDir(newDir);
77
99
  return newDir;
78
100
  }
79
101
  function getDbPath() {
@@ -87,8 +109,7 @@ function ensureDir(filePath) {
87
109
  if (filePath === ":memory:")
88
110
  return;
89
111
  const dir = dirname(resolve(filePath));
90
- if (!existsSync2(dir))
91
- mkdirSync(dir, { recursive: true });
112
+ ensureCreatedPrivateDir(dir);
92
113
  }
93
114
  function getDatabase(path) {
94
115
  if (_db)
@@ -99,6 +120,7 @@ function getDatabase(path) {
99
120
  db.exec("PRAGMA journal_mode=WAL");
100
121
  db.exec("PRAGMA foreign_keys=ON");
101
122
  runMigrations(db);
123
+ protectDatabaseArtifacts(dbPath);
102
124
  _db = db;
103
125
  return db;
104
126
  }
@@ -12032,60 +12054,1051 @@ function getStorageStatus(db = getDatabase()) {
12032
12054
  };
12033
12055
  }
12034
12056
 
12035
- // src/mcp/storage-tools.ts
12036
- function ok(data) {
12037
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
12038
- }
12039
- function err(error) {
12040
- return {
12041
- content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
12042
- isError: true
12043
- };
12044
- }
12045
- function cloudStatus() {
12046
- return {
12047
- service: "contacts",
12048
- mode: "local",
12049
- remote_sync: {
12050
- configured: false,
12051
- reason: "Contacts no longer uses the deprecated shared cloud runtime. Use contacts-owned storage until repo-native remote sync is configured."
12052
- },
12053
- storage: getStorageStatus()
12054
- };
12055
- }
12056
- function unsupportedCloudSync(operation) {
12057
- return {
12058
- ok: false,
12059
- operation,
12060
- service: "contacts",
12061
- mode: "local",
12062
- error: "Repo-native contacts remote sync is not configured in this package yet.",
12063
- next: "Use contacts_storage_status to inspect local data."
12064
- };
12065
- }
12066
- function registerContactsStorageTools(server) {
12067
- server.tool("contacts_storage_status", "Show contacts-owned local database storage status", {}, async () => {
12068
- try {
12069
- return ok(getStorageStatus());
12070
- } catch (error) {
12071
- return err(error);
12072
- }
12073
- });
12074
- server.tool("contacts_cloud_status", "Compatibility status for contacts-owned storage and remote sync availability", {}, async () => {
12075
- try {
12076
- return ok(cloudStatus());
12077
- } catch (error) {
12078
- return err(error);
12079
- }
12080
- });
12081
- server.tool("contacts_cloud_push", "Report contacts remote push availability", { tables: exports_external.string().optional().describe("Accepted for compatibility; remote sync is not configured") }, async () => ({
12082
- content: [{ type: "text", text: JSON.stringify(unsupportedCloudSync("push"), null, 2) }],
12083
- isError: true
12084
- }));
12085
- server.tool("contacts_cloud_pull", "Report contacts remote pull availability", { tables: exports_external.string().optional().describe("Accepted for compatibility; remote sync is not configured") }, async () => ({
12086
- content: [{ type: "text", text: JSON.stringify(unsupportedCloudSync("pull"), null, 2) }],
12087
- isError: true
12088
- }));
12057
+ // src/db/remote-sync.ts
12058
+ init_database();
12059
+
12060
+ // src/db/pg-migrations.ts
12061
+ var PG_MIGRATIONS = [
12062
+ `
12063
+ CREATE TABLE IF NOT EXISTS companies (
12064
+ id TEXT PRIMARY KEY,
12065
+ name TEXT NOT NULL,
12066
+ domain TEXT,
12067
+ logo_url TEXT,
12068
+ description TEXT,
12069
+ industry TEXT,
12070
+ size TEXT,
12071
+ founded_year INTEGER,
12072
+ notes TEXT,
12073
+ custom_fields TEXT NOT NULL DEFAULT '{}',
12074
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12075
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12076
+ );
12077
+
12078
+ CREATE TABLE IF NOT EXISTS contacts (
12079
+ id TEXT PRIMARY KEY,
12080
+ first_name TEXT NOT NULL DEFAULT '',
12081
+ last_name TEXT NOT NULL DEFAULT '',
12082
+ display_name TEXT NOT NULL,
12083
+ nickname TEXT,
12084
+ avatar_url TEXT,
12085
+ notes TEXT,
12086
+ birthday TEXT,
12087
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
12088
+ job_title TEXT,
12089
+ source TEXT NOT NULL DEFAULT 'manual',
12090
+ custom_fields TEXT NOT NULL DEFAULT '{}',
12091
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12092
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12093
+ );
12094
+
12095
+ CREATE TABLE IF NOT EXISTS tags (
12096
+ id TEXT PRIMARY KEY,
12097
+ name TEXT NOT NULL UNIQUE,
12098
+ color TEXT NOT NULL DEFAULT '#6366f1',
12099
+ description TEXT,
12100
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12101
+ );
12102
+
12103
+ CREATE TABLE IF NOT EXISTS contact_tags (
12104
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12105
+ tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
12106
+ PRIMARY KEY (contact_id, tag_id)
12107
+ );
12108
+
12109
+ CREATE TABLE IF NOT EXISTS company_tags (
12110
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
12111
+ tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
12112
+ PRIMARY KEY (company_id, tag_id)
12113
+ );
12114
+
12115
+ CREATE TABLE IF NOT EXISTS emails (
12116
+ id TEXT PRIMARY KEY,
12117
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
12118
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
12119
+ address TEXT NOT NULL,
12120
+ type TEXT NOT NULL DEFAULT 'work' CHECK(type IN ('work','personal','other')),
12121
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
12122
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12123
+ );
12124
+
12125
+ CREATE TABLE IF NOT EXISTS phones (
12126
+ id TEXT PRIMARY KEY,
12127
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
12128
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
12129
+ number TEXT NOT NULL,
12130
+ country_code TEXT,
12131
+ type TEXT NOT NULL DEFAULT 'mobile' CHECK(type IN ('mobile','work','home','fax','whatsapp','other')),
12132
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
12133
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12134
+ );
12135
+
12136
+ CREATE TABLE IF NOT EXISTS addresses (
12137
+ id TEXT PRIMARY KEY,
12138
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
12139
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
12140
+ type TEXT NOT NULL DEFAULT 'physical' CHECK(type IN ('physical','mailing','billing','virtual','other')),
12141
+ street TEXT,
12142
+ city TEXT,
12143
+ state TEXT,
12144
+ zip TEXT,
12145
+ country TEXT,
12146
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
12147
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12148
+ );
12149
+
12150
+ CREATE TABLE IF NOT EXISTS social_profiles (
12151
+ id TEXT PRIMARY KEY,
12152
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
12153
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
12154
+ platform TEXT NOT NULL CHECK(platform IN ('twitter','linkedin','github','instagram','telegram','discord','youtube','tiktok','bluesky','facebook','whatsapp','snapchat','reddit','other')),
12155
+ handle TEXT,
12156
+ url TEXT,
12157
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
12158
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12159
+ );
12160
+
12161
+ CREATE TABLE IF NOT EXISTS contact_relationships (
12162
+ id TEXT PRIMARY KEY,
12163
+ contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12164
+ contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12165
+ relationship_type TEXT NOT NULL CHECK(relationship_type IN ('colleague','friend','family','reports_to','mentor','investor','partner','client','vendor','other')),
12166
+ notes TEXT,
12167
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12168
+ );
12169
+
12170
+ CREATE TABLE IF NOT EXISTS activity_log (
12171
+ id TEXT PRIMARY KEY,
12172
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
12173
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
12174
+ action TEXT NOT NULL,
12175
+ details TEXT,
12176
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12177
+ );
12178
+
12179
+ CREATE TABLE IF NOT EXISTS webhooks (
12180
+ id TEXT PRIMARY KEY,
12181
+ url TEXT NOT NULL,
12182
+ events TEXT NOT NULL DEFAULT '["*"]',
12183
+ secret TEXT,
12184
+ active BOOLEAN NOT NULL DEFAULT TRUE,
12185
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12186
+ );
12187
+
12188
+ -- Full-text search using PostgreSQL tsvector instead of FTS5
12189
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS search_vector tsvector;
12190
+ CREATE INDEX IF NOT EXISTS idx_contacts_search ON contacts USING GIN(search_vector);
12191
+
12192
+ CREATE OR REPLACE FUNCTION contacts_search_vector_update() RETURNS trigger AS $$
12193
+ BEGIN
12194
+ NEW.search_vector :=
12195
+ setweight(to_tsvector('simple', COALESCE(NEW.display_name, '')), 'A') ||
12196
+ setweight(to_tsvector('simple', COALESCE(NEW.first_name, '')), 'A') ||
12197
+ setweight(to_tsvector('simple', COALESCE(NEW.last_name, '')), 'A') ||
12198
+ setweight(to_tsvector('simple', COALESCE(NEW.nickname, '')), 'B') ||
12199
+ setweight(to_tsvector('english', COALESCE(NEW.notes, '')), 'C') ||
12200
+ setweight(to_tsvector('simple', COALESCE(NEW.job_title, '')), 'B');
12201
+ RETURN NEW;
12202
+ END;
12203
+ $$ LANGUAGE plpgsql;
12204
+
12205
+ DROP TRIGGER IF EXISTS contacts_search_vector_trigger ON contacts;
12206
+ CREATE TRIGGER contacts_search_vector_trigger
12207
+ BEFORE INSERT OR UPDATE OF display_name, first_name, last_name, nickname, notes, job_title ON contacts
12208
+ FOR EACH ROW EXECUTE FUNCTION contacts_search_vector_update();
12209
+
12210
+ -- Backfill
12211
+ UPDATE contacts SET search_vector =
12212
+ setweight(to_tsvector('simple', COALESCE(display_name, '')), 'A') ||
12213
+ setweight(to_tsvector('simple', COALESCE(first_name, '')), 'A') ||
12214
+ setweight(to_tsvector('simple', COALESCE(last_name, '')), 'A') ||
12215
+ setweight(to_tsvector('simple', COALESCE(nickname, '')), 'B') ||
12216
+ setweight(to_tsvector('english', COALESCE(notes, '')), 'C') ||
12217
+ setweight(to_tsvector('simple', COALESCE(job_title, '')), 'B')
12218
+ WHERE search_vector IS NULL;
12219
+
12220
+ CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
12221
+ INSERT INTO _migrations (version) VALUES (0) ON CONFLICT DO NOTHING;
12222
+ `,
12223
+ `
12224
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS last_contacted_at TEXT;
12225
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS website TEXT;
12226
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS preferred_contact_method TEXT;
12227
+
12228
+ CREATE TABLE IF NOT EXISTS groups (
12229
+ id TEXT PRIMARY KEY,
12230
+ name TEXT NOT NULL UNIQUE,
12231
+ description TEXT,
12232
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12233
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12234
+ );
12235
+
12236
+ CREATE TABLE IF NOT EXISTS contact_groups (
12237
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12238
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
12239
+ PRIMARY KEY (contact_id, group_id)
12240
+ );
12241
+
12242
+ INSERT INTO _migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
12243
+ `,
12244
+ `
12245
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active';
12246
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS follow_up_at TEXT;
12247
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
12248
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS project_id TEXT;
12249
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
12250
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS project_id TEXT;
12251
+
12252
+ CREATE TABLE IF NOT EXISTS company_groups (
12253
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
12254
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
12255
+ PRIMARY KEY (company_id, group_id)
12256
+ );
12257
+
12258
+ INSERT INTO _migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
12259
+ `,
12260
+ `
12261
+ CREATE TABLE IF NOT EXISTS company_relationships (
12262
+ id TEXT PRIMARY KEY,
12263
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12264
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
12265
+ relationship_type TEXT NOT NULL CHECK(relationship_type IN ('client','vendor','partner','employee','contractor','investor','advisor','other')),
12266
+ notes TEXT,
12267
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12268
+ );
12269
+
12270
+ CREATE INDEX IF NOT EXISTS idx_company_relationships_contact ON company_relationships(contact_id);
12271
+ CREATE INDEX IF NOT EXISTS idx_company_relationships_company ON company_relationships(company_id);
12272
+
12273
+ INSERT INTO _migrations (version) VALUES (3) ON CONFLICT DO NOTHING;
12274
+ `,
12275
+ `
12276
+ ALTER TABLE groups ADD COLUMN IF NOT EXISTS project_id TEXT;
12277
+
12278
+ CREATE TABLE IF NOT EXISTS contact_projects (
12279
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12280
+ project_id TEXT NOT NULL,
12281
+ PRIMARY KEY (contact_id, project_id)
12282
+ );
12283
+
12284
+ CREATE INDEX IF NOT EXISTS idx_contact_projects_project ON contact_projects(project_id);
12285
+ CREATE INDEX IF NOT EXISTS idx_contact_projects_contact ON contact_projects(contact_id);
12286
+
12287
+ INSERT INTO contact_projects (contact_id, project_id)
12288
+ SELECT id, project_id FROM contacts WHERE project_id IS NOT NULL
12289
+ ON CONFLICT DO NOTHING;
12290
+
12291
+ INSERT INTO _migrations (version) VALUES (4) ON CONFLICT DO NOTHING;
12292
+ `,
12293
+ `
12294
+ CREATE TABLE IF NOT EXISTS contact_notes (
12295
+ id TEXT PRIMARY KEY,
12296
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12297
+ body TEXT NOT NULL,
12298
+ created_by TEXT,
12299
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12300
+ );
12301
+
12302
+ CREATE INDEX IF NOT EXISTS idx_contact_notes_contact ON contact_notes(contact_id);
12303
+
12304
+ INSERT INTO _migrations (version) VALUES (5) ON CONFLICT DO NOTHING;
12305
+ `,
12306
+ `
12307
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS is_owned_entity BOOLEAN NOT NULL DEFAULT FALSE;
12308
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS entity_type TEXT CHECK(entity_type IN ('operating','holding','dissolved','nonprofit','trust','branch','other'));
12309
+
12310
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS start_date TEXT;
12311
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS end_date TEXT;
12312
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS is_primary BOOLEAN NOT NULL DEFAULT FALSE;
12313
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','inactive','ended'));
12314
+
12315
+ CREATE TABLE IF NOT EXISTS org_members (
12316
+ id TEXT PRIMARY KEY,
12317
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
12318
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12319
+ title TEXT,
12320
+ specialization TEXT,
12321
+ office_phone TEXT,
12322
+ response_sla_hours INTEGER,
12323
+ notes TEXT,
12324
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12325
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12326
+ UNIQUE(company_id, contact_id)
12327
+ );
12328
+
12329
+ CREATE TABLE IF NOT EXISTS vendor_communications (
12330
+ id TEXT PRIMARY KEY,
12331
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
12332
+ contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
12333
+ comm_date TEXT NOT NULL,
12334
+ type TEXT NOT NULL DEFAULT 'email' CHECK(type IN ('email','call','meeting','invoice_request','invoice_received','payment','dispute','other')),
12335
+ direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound','outbound')),
12336
+ subject TEXT,
12337
+ body TEXT,
12338
+ status TEXT NOT NULL DEFAULT 'sent' CHECK(status IN ('sent','awaiting_response','responded','no_response','resolved')),
12339
+ invoice_amount DOUBLE PRECISION,
12340
+ invoice_currency TEXT,
12341
+ invoice_ref TEXT,
12342
+ follow_up_date TEXT,
12343
+ follow_up_done BOOLEAN NOT NULL DEFAULT FALSE,
12344
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12345
+ );
12346
+
12347
+ CREATE TABLE IF NOT EXISTS contact_tasks (
12348
+ id TEXT PRIMARY KEY,
12349
+ title TEXT NOT NULL,
12350
+ description TEXT,
12351
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12352
+ assigned_by TEXT,
12353
+ deadline TEXT,
12354
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','awaiting_response','in_progress','completed','cancelled','escalated')),
12355
+ priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')),
12356
+ entity_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
12357
+ linked_todos_task_id TEXT,
12358
+ escalation_rules TEXT NOT NULL DEFAULT '[]',
12359
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12360
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12361
+ );
12362
+
12363
+ CREATE TABLE IF NOT EXISTS applications (
12364
+ id TEXT PRIMARY KEY,
12365
+ program_name TEXT NOT NULL,
12366
+ provider_company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
12367
+ type TEXT NOT NULL DEFAULT 'other' CHECK(type IN ('ai_credits','grant','startup_program','visa','trademark','tax_filing','loan','other')),
12368
+ value_usd DOUBLE PRECISION,
12369
+ applicant_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
12370
+ primary_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
12371
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','submitted','pending','approved','rejected','follow_up_needed','expired','cancelled')),
12372
+ submitted_date TEXT,
12373
+ decision_date TEXT,
12374
+ follow_up_date TEXT,
12375
+ notes TEXT,
12376
+ method TEXT CHECK(method IN ('email','form','typeform','hubspot','manual','browser','feathery','other')),
12377
+ form_url TEXT,
12378
+ metadata TEXT NOT NULL DEFAULT '{}',
12379
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12380
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12381
+ );
12382
+
12383
+ ALTER TABLE contact_notes ADD COLUMN IF NOT EXISTS company_id TEXT REFERENCES companies(id) ON DELETE SET NULL;
12384
+
12385
+ CREATE TABLE IF NOT EXISTS contact_embeddings (
12386
+ contact_id TEXT PRIMARY KEY REFERENCES contacts(id) ON DELETE CASCADE,
12387
+ embedding TEXT NOT NULL,
12388
+ model TEXT NOT NULL DEFAULT 'tfidf',
12389
+ embedded_text TEXT,
12390
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12391
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12392
+ );
12393
+
12394
+ INSERT INTO _migrations (version) VALUES (6) ON CONFLICT DO NOTHING;
12395
+ `,
12396
+ `
12397
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS do_not_contact BOOLEAN NOT NULL DEFAULT FALSE;
12398
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5);
12399
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS timezone TEXT;
12400
+
12401
+ CREATE TABLE IF NOT EXISTS deals (
12402
+ id TEXT PRIMARY KEY,
12403
+ title TEXT NOT NULL,
12404
+ contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
12405
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
12406
+ stage TEXT NOT NULL DEFAULT 'lead' CHECK(stage IN ('lead','qualified','proposal','negotiation','won','lost','cancelled')),
12407
+ value_usd DOUBLE PRECISION,
12408
+ currency TEXT NOT NULL DEFAULT 'USD',
12409
+ close_date TEXT,
12410
+ notes TEXT,
12411
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12412
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12413
+ );
12414
+
12415
+ CREATE TABLE IF NOT EXISTS events (
12416
+ id TEXT PRIMARY KEY,
12417
+ title TEXT NOT NULL,
12418
+ type TEXT NOT NULL DEFAULT 'meeting' CHECK(type IN ('meeting','call','lunch','email','demo','conference','intro','other')),
12419
+ event_date TEXT NOT NULL,
12420
+ duration_min INTEGER,
12421
+ contact_ids TEXT NOT NULL DEFAULT '[]',
12422
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
12423
+ notes TEXT,
12424
+ outcome TEXT,
12425
+ deal_id TEXT REFERENCES deals(id) ON DELETE SET NULL,
12426
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12427
+ );
12428
+
12429
+ INSERT INTO _migrations (version) VALUES (7) ON CONFLICT DO NOTHING;
12430
+ `,
12431
+ `
12432
+ CREATE TABLE IF NOT EXISTS contact_field_history (
12433
+ id TEXT PRIMARY KEY,
12434
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12435
+ field_name TEXT NOT NULL,
12436
+ old_value TEXT,
12437
+ new_value TEXT,
12438
+ valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12439
+ source TEXT,
12440
+ confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
12441
+ created_by TEXT,
12442
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12443
+ );
12444
+
12445
+ CREATE TABLE IF NOT EXISTS job_history (
12446
+ id TEXT PRIMARY KEY,
12447
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12448
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
12449
+ company_name TEXT NOT NULL,
12450
+ title TEXT,
12451
+ start_date TEXT,
12452
+ end_date TEXT,
12453
+ is_current BOOLEAN NOT NULL DEFAULT FALSE,
12454
+ inferred BOOLEAN NOT NULL DEFAULT FALSE,
12455
+ source TEXT,
12456
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12457
+ );
12458
+
12459
+ CREATE TABLE IF NOT EXISTS contact_learnings (
12460
+ id TEXT PRIMARY KEY,
12461
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12462
+ content TEXT NOT NULL,
12463
+ type TEXT NOT NULL DEFAULT 'fact' CHECK(type IN ('preference','fact','inference','warning','signal')),
12464
+ confidence INTEGER NOT NULL DEFAULT 70 CHECK(confidence BETWEEN 0 AND 100),
12465
+ importance INTEGER NOT NULL DEFAULT 5 CHECK(importance BETWEEN 1 AND 10),
12466
+ learned_by TEXT,
12467
+ session_id TEXT,
12468
+ visibility TEXT NOT NULL DEFAULT 'shared' CHECK(visibility IN ('private','shared','human')),
12469
+ tags TEXT NOT NULL DEFAULT '[]',
12470
+ confirmed_count INTEGER NOT NULL DEFAULT 0,
12471
+ contradicts_id TEXT REFERENCES contact_learnings(id) ON DELETE SET NULL,
12472
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12473
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12474
+ );
12475
+
12476
+ CREATE TABLE IF NOT EXISTS contact_locks (
12477
+ id TEXT PRIMARY KEY,
12478
+ contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
12479
+ agent_name TEXT NOT NULL,
12480
+ reason TEXT,
12481
+ acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12482
+ expires_at TIMESTAMPTZ NOT NULL,
12483
+ session_id TEXT
12484
+ );
12485
+
12486
+ CREATE TABLE IF NOT EXISTS contact_agent_activity (
12487
+ id TEXT PRIMARY KEY,
12488
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12489
+ agent_name TEXT NOT NULL,
12490
+ action TEXT NOT NULL,
12491
+ details TEXT,
12492
+ session_id TEXT,
12493
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12494
+ );
12495
+
12496
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS strength_score INTEGER NOT NULL DEFAULT 50;
12497
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS interaction_count INTEGER NOT NULL DEFAULT 0;
12498
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS last_interaction TEXT;
12499
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS relationship_status TEXT NOT NULL DEFAULT 'stable' CHECK(relationship_status IN ('warming','stable','cooling','ghost'));
12500
+
12501
+ CREATE TABLE IF NOT EXISTS contact_identities (
12502
+ id TEXT PRIMARY KEY,
12503
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12504
+ system TEXT NOT NULL,
12505
+ external_id TEXT NOT NULL,
12506
+ external_url TEXT,
12507
+ confidence TEXT NOT NULL DEFAULT 'inferred' CHECK(confidence IN ('verified','inferred')),
12508
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12509
+ UNIQUE(system, external_id)
12510
+ );
12511
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS canonical_id TEXT;
12512
+
12513
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS relationship_health INTEGER NOT NULL DEFAULT 50;
12514
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS avg_response_hours DOUBLE PRECISION;
12515
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS preferred_channel TEXT;
12516
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS engagement_status TEXT NOT NULL DEFAULT 'new' CHECK(engagement_status IN ('warming','stable','cooling','ghost','new'));
12517
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS interaction_count_30d INTEGER NOT NULL DEFAULT 0;
12518
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS interaction_count_90d INTEGER NOT NULL DEFAULT 0;
12519
+
12520
+ CREATE TABLE IF NOT EXISTS contact_field_confidence (
12521
+ id TEXT PRIMARY KEY,
12522
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12523
+ field_name TEXT NOT NULL,
12524
+ confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
12525
+ source TEXT,
12526
+ last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12527
+ UNIQUE(contact_id, field_name)
12528
+ );
12529
+
12530
+ CREATE TABLE IF NOT EXISTS org_chart_edges (
12531
+ id TEXT PRIMARY KEY,
12532
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
12533
+ contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12534
+ contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12535
+ edge_type TEXT NOT NULL CHECK(edge_type IN ('reports_to','manages','collaborates_with','peer')),
12536
+ inferred BOOLEAN NOT NULL DEFAULT FALSE,
12537
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12538
+ UNIQUE(company_id, contact_a_id, contact_b_id, edge_type)
12539
+ );
12540
+
12541
+ CREATE TABLE IF NOT EXISTS deal_contact_roles (
12542
+ id TEXT PRIMARY KEY,
12543
+ deal_id TEXT NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
12544
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12545
+ account_role TEXT NOT NULL CHECK(account_role IN ('economic_buyer','technical_evaluator','champion','blocker','influencer','user','sponsor','other')),
12546
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12547
+ UNIQUE(deal_id, contact_id)
12548
+ );
12549
+
12550
+ INSERT INTO _migrations (version) VALUES (8) ON CONFLICT DO NOTHING;
12551
+ `,
12552
+ `
12553
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK(sensitivity IN ('normal','confidential','restricted'));
12554
+
12555
+ CREATE TABLE IF NOT EXISTS contact_documents (
12556
+ id TEXT PRIMARY KEY,
12557
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
12558
+ doc_type TEXT NOT NULL,
12559
+ label TEXT,
12560
+ encrypted_value TEXT NOT NULL,
12561
+ iv TEXT NOT NULL,
12562
+ encrypted_file_path TEXT,
12563
+ metadata TEXT NOT NULL DEFAULT '{}',
12564
+ expires_at TEXT,
12565
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12566
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12567
+ );
12568
+
12569
+ CREATE TABLE IF NOT EXISTS contact_health (
12570
+ id TEXT PRIMARY KEY,
12571
+ contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
12572
+ blood_type TEXT,
12573
+ allergies TEXT NOT NULL DEFAULT '[]',
12574
+ medical_conditions TEXT NOT NULL DEFAULT '[]',
12575
+ medications TEXT NOT NULL DEFAULT '[]',
12576
+ emergency_contacts TEXT NOT NULL DEFAULT '[]',
12577
+ health_insurance_provider TEXT,
12578
+ health_insurance_id TEXT,
12579
+ primary_physician TEXT,
12580
+ primary_physician_phone TEXT,
12581
+ organ_donor BOOLEAN NOT NULL DEFAULT FALSE,
12582
+ notes TEXT,
12583
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12584
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12585
+ );
12586
+
12587
+ INSERT INTO _migrations (version) VALUES (9) ON CONFLICT DO NOTHING;
12588
+ `,
12589
+ `
12590
+ CREATE TABLE IF NOT EXISTS feedback (
12591
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
12592
+ message TEXT NOT NULL,
12593
+ email TEXT,
12594
+ category TEXT DEFAULT 'general',
12595
+ version TEXT,
12596
+ machine_id TEXT,
12597
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12598
+ );
12599
+
12600
+ INSERT INTO _migrations (version) VALUES (10) ON CONFLICT DO NOTHING;
12601
+ `
12602
+ ];
12603
+
12604
+ // src/db/remote-storage.ts
12605
+ import pg from "pg";
12606
+ var LOCAL_PG_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
12607
+ function parsePgUrl(connectionString) {
12608
+ try {
12609
+ return new URL(connectionString);
12610
+ } catch {
12611
+ return;
12612
+ }
12613
+ }
12614
+ function isLocalPgHost(hostname2) {
12615
+ return LOCAL_PG_HOSTS.has(hostname2.replace(/^\[|\]$/g, ""));
12616
+ }
12617
+ function normalizedParams(url, name) {
12618
+ return url.searchParams.getAll(name).map((value) => value.trim().toLowerCase());
12619
+ }
12620
+ function isFalseySslValue(value) {
12621
+ return value === "0" || value === "false" || value === "no" || value === "disable" || value === "disabled";
12622
+ }
12623
+ function isTruthyValue(value) {
12624
+ return value === "1" || value === "true" || value === "yes";
12625
+ }
12626
+ function sanitizedConnectionString(url) {
12627
+ for (const param of ["ssl", "sslmode", "uselibpqcompat"]) {
12628
+ url.searchParams.delete(param);
12629
+ }
12630
+ return url.toString();
12631
+ }
12632
+ function buildPgPoolConfig(connectionString) {
12633
+ const url = parsePgUrl(connectionString);
12634
+ if (!url) {
12635
+ return { connectionString, ssl: { rejectUnauthorized: true } };
12636
+ }
12637
+ const localHost = isLocalPgHost(url.hostname);
12638
+ const sslModes = normalizedParams(url, "sslmode");
12639
+ const sslParams = normalizedParams(url, "ssl");
12640
+ const libpqCompatParams = normalizedParams(url, "uselibpqcompat");
12641
+ const explicitTlsOff = sslModes.includes("disable") || sslParams.some(isFalseySslValue);
12642
+ const weakTlsMode = sslModes.some((mode) => mode === "allow" || mode === "prefer" || mode === "no-verify");
12643
+ const libpqCompatibility = libpqCompatParams.some(isTruthyValue);
12644
+ const sanitized = sanitizedConnectionString(url);
12645
+ if ((explicitTlsOff || weakTlsMode || libpqCompatibility) && !localHost) {
12646
+ throw new Error("Remote contacts PostgreSQL storage must use verified TLS.");
12647
+ }
12648
+ if (localHost && (explicitTlsOff || sslModes.length === 0 && sslParams.length === 0)) {
12649
+ return { connectionString: sanitized };
12650
+ }
12651
+ return { connectionString: sanitized, ssl: { rejectUnauthorized: true } };
12652
+ }
12653
+ function translatePlaceholders(sql) {
12654
+ let index = 0;
12655
+ return sql.replace(/\?/g, () => `$${++index}`);
12656
+ }
12657
+ function normalizeParams2(params) {
12658
+ const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
12659
+ return flat.map((value) => value === undefined ? null : value);
12660
+ }
12661
+
12662
+ class PgAdapterAsync {
12663
+ pool;
12664
+ constructor(connectionString) {
12665
+ this.pool = new pg.Pool(buildPgPoolConfig(connectionString));
12666
+ }
12667
+ async run(sql, ...params) {
12668
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
12669
+ return { changes: result.rowCount ?? 0 };
12670
+ }
12671
+ async get(sql, ...params) {
12672
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
12673
+ return result.rows[0] ?? null;
12674
+ }
12675
+ async all(sql, ...params) {
12676
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
12677
+ return result.rows;
12678
+ }
12679
+ async exec(sql) {
12680
+ await this.pool.query(sql);
12681
+ }
12682
+ async close() {
12683
+ await this.pool.end();
12684
+ }
12685
+ }
12686
+
12687
+ // src/db/remote-sync.ts
12688
+ var CONTACTS_REMOTE_TABLES = [
12689
+ "companies",
12690
+ "contacts",
12691
+ "tags",
12692
+ "contact_tags",
12693
+ "company_tags",
12694
+ "emails",
12695
+ "phones",
12696
+ "addresses",
12697
+ "social_profiles",
12698
+ "contact_relationships",
12699
+ "activity_log",
12700
+ "webhooks",
12701
+ "groups",
12702
+ "contact_groups",
12703
+ "company_groups",
12704
+ "company_relationships",
12705
+ "contact_projects",
12706
+ "contact_notes",
12707
+ "org_members",
12708
+ "vendor_communications",
12709
+ "contact_tasks",
12710
+ "applications",
12711
+ "deals",
12712
+ "events",
12713
+ "contact_field_history",
12714
+ "job_history",
12715
+ "contact_learnings",
12716
+ "contact_locks",
12717
+ "contact_agent_activity",
12718
+ "contact_identities",
12719
+ "contact_field_confidence",
12720
+ "org_chart_edges",
12721
+ "deal_contact_roles",
12722
+ "contact_embeddings",
12723
+ "contact_documents",
12724
+ "contact_health",
12725
+ "feedback"
12726
+ ];
12727
+ var CONTACTS_REMOTE_SENSITIVE_TABLES = [
12728
+ "webhooks",
12729
+ "contact_documents",
12730
+ "contact_health"
12731
+ ];
12732
+ var CONTACTS_REMOTE_DEFAULT_TABLES = CONTACTS_REMOTE_TABLES.filter((table) => !CONTACTS_REMOTE_SENSITIVE_TABLES.includes(table));
12733
+ var PRIMARY_KEYS = {
12734
+ companies: ["id"],
12735
+ contacts: ["id"],
12736
+ tags: ["id"],
12737
+ contact_tags: ["contact_id", "tag_id"],
12738
+ company_tags: ["company_id", "tag_id"],
12739
+ emails: ["id"],
12740
+ phones: ["id"],
12741
+ addresses: ["id"],
12742
+ social_profiles: ["id"],
12743
+ contact_relationships: ["id"],
12744
+ activity_log: ["id"],
12745
+ webhooks: ["id"],
12746
+ groups: ["id"],
12747
+ contact_groups: ["contact_id", "group_id"],
12748
+ company_groups: ["company_id", "group_id"],
12749
+ company_relationships: ["id"],
12750
+ contact_projects: ["contact_id", "project_id"],
12751
+ contact_notes: ["id"],
12752
+ org_members: ["id"],
12753
+ vendor_communications: ["id"],
12754
+ contact_tasks: ["id"],
12755
+ applications: ["id"],
12756
+ deals: ["id"],
12757
+ events: ["id"],
12758
+ contact_field_history: ["id"],
12759
+ job_history: ["id"],
12760
+ contact_learnings: ["id"],
12761
+ contact_locks: ["id"],
12762
+ contact_agent_activity: ["id"],
12763
+ contact_identities: ["id"],
12764
+ contact_field_confidence: ["id"],
12765
+ org_chart_edges: ["id"],
12766
+ deal_contact_roles: ["id"],
12767
+ contact_embeddings: ["contact_id"],
12768
+ contact_documents: ["id"],
12769
+ contact_health: ["id"],
12770
+ feedback: ["id"]
12771
+ };
12772
+ var CONTACTS_REMOTE_ENV = [
12773
+ "HASNA_CONTACTS_POSTGRES_URL",
12774
+ "OPEN_CONTACTS_POSTGRES_URL",
12775
+ "CONTACTS_POSTGRES_URL",
12776
+ "HASNA_CONTACTS_DATABASE_URL",
12777
+ "CONTACTS_DATABASE_URL"
12778
+ ];
12779
+
12780
+ class ContactsRemoteSyncError extends Error {
12781
+ results;
12782
+ constructor(message, results) {
12783
+ super(message);
12784
+ this.results = results;
12785
+ this.name = "ContactsRemoteSyncError";
12786
+ }
12787
+ }
12788
+ function getRemoteDatabaseUrl() {
12789
+ for (const name of CONTACTS_REMOTE_ENV) {
12790
+ const value = process.env[name];
12791
+ if (value)
12792
+ return value;
12793
+ }
12794
+ return null;
12795
+ }
12796
+ async function getRemotePg() {
12797
+ const url = getRemoteDatabaseUrl();
12798
+ if (!url)
12799
+ throw new Error(`Missing contacts remote database URL. Set one of: ${CONTACTS_REMOTE_ENV.join(", ")}`);
12800
+ return new PgAdapterAsync(url);
12801
+ }
12802
+ async function runRemoteMigrations(remote) {
12803
+ await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
12804
+ for (const sql of PG_MIGRATIONS)
12805
+ await remote.run(sql);
12806
+ }
12807
+ async function pushRemote(options) {
12808
+ const remote = await getRemotePg();
12809
+ const db = getDatabase();
12810
+ try {
12811
+ await runRemoteMigrations(remote);
12812
+ const results = [];
12813
+ for (const table of resolveRemoteTables(options?.tables))
12814
+ results.push(await pushTable(db, remote, table));
12815
+ recordSyncMeta(db, "push", results);
12816
+ throwIfSyncErrors("push", results);
12817
+ return results;
12818
+ } finally {
12819
+ await remote.close();
12820
+ }
12821
+ }
12822
+ async function pullRemote(options) {
12823
+ const remote = await getRemotePg();
12824
+ const db = getDatabase();
12825
+ try {
12826
+ await runRemoteMigrations(remote);
12827
+ const results = [];
12828
+ for (const table of resolveRemoteTables(options?.tables))
12829
+ results.push(await pullTable(remote, db, table));
12830
+ recordSyncMeta(db, "pull", results);
12831
+ throwIfSyncErrors("pull", results);
12832
+ return results;
12833
+ } finally {
12834
+ await remote.close();
12835
+ }
12836
+ }
12837
+ async function syncRemote(options) {
12838
+ const pull = await pullRemote(options);
12839
+ const push = await pushRemote(options);
12840
+ return { pull, push };
12841
+ }
12842
+ function getSyncMetaAll() {
12843
+ const db = getDatabase();
12844
+ ensureSyncMetaTable(db);
12845
+ return db.query("SELECT table_name, last_synced_at, direction FROM _contacts_sync_meta ORDER BY table_name, direction").all();
12846
+ }
12847
+ function getRemoteStatus() {
12848
+ return {
12849
+ configured: Boolean(getRemoteDatabaseUrl()),
12850
+ env: CONTACTS_REMOTE_ENV,
12851
+ default_tables: CONTACTS_REMOTE_DEFAULT_TABLES,
12852
+ sensitive_tables: CONTACTS_REMOTE_SENSITIVE_TABLES,
12853
+ tables: CONTACTS_REMOTE_TABLES,
12854
+ sync: getSyncMetaAll()
12855
+ };
12856
+ }
12857
+ function resolveRemoteTables(tables) {
12858
+ if (!tables || tables.length === 0)
12859
+ return [...CONTACTS_REMOTE_DEFAULT_TABLES];
12860
+ const allowed = new Set(CONTACTS_REMOTE_TABLES);
12861
+ const requested = tables.map((table) => table.trim()).filter(Boolean);
12862
+ const invalid = requested.filter((table) => !allowed.has(table));
12863
+ if (invalid.length > 0)
12864
+ throw new Error(`Unknown contacts sync table(s): ${invalid.join(", ")}`);
12865
+ return requested;
12866
+ }
12867
+ async function pushTable(db, remote, table) {
12868
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
12869
+ try {
12870
+ const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
12871
+ result.rowsRead = rows.length;
12872
+ if (rows.length === 0)
12873
+ return result;
12874
+ const remoteColumns = await getRemoteColumns(remote, table);
12875
+ const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
12876
+ result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
12877
+ } catch (error) {
12878
+ result.errors.push(error instanceof Error ? error.message : String(error));
12879
+ }
12880
+ return result;
12881
+ }
12882
+ async function pullTable(remote, db, table) {
12883
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
12884
+ try {
12885
+ const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
12886
+ result.rowsRead = rows.length;
12887
+ if (rows.length === 0)
12888
+ return result;
12889
+ const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
12890
+ result.rowsWritten = upsertSqlite(db, table, columns, rows);
12891
+ } catch (error) {
12892
+ result.errors.push(error instanceof Error ? error.message : String(error));
12893
+ }
12894
+ return result;
12895
+ }
12896
+ async function getRemoteColumns(remote, table) {
12897
+ const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
12898
+ return new Map(rows.map((row) => [row.column_name, row.data_type]));
12899
+ }
12900
+ function filterRemoteColumns(remoteColumns, columns) {
12901
+ if (remoteColumns.size === 0)
12902
+ return columns;
12903
+ return columns.filter((column) => remoteColumns.has(column));
12904
+ }
12905
+ function filterLocalColumns(db, table, columns) {
12906
+ const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
12907
+ const allowed = new Set(rows.map((row) => row.name));
12908
+ return columns.filter((column) => allowed.has(column));
12909
+ }
12910
+ async function upsertPg(remote, table, columns, rows, remoteColumns) {
12911
+ if (columns.length === 0)
12912
+ return 0;
12913
+ const primaryKeys = PRIMARY_KEYS[table];
12914
+ const columnList = columns.map(quoteIdent).join(", ");
12915
+ const placeholders = columns.map(() => "?").join(", ");
12916
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
12917
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
12918
+ const fallbackKey = primaryKeys[0];
12919
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
12920
+ const whereClause = conflictWhereClause(table, columns, "EXCLUDED");
12921
+ for (const row of rows) {
12922
+ await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, columns.map((column) => toPostgresValue(row[column], remoteColumns.get(column))));
12923
+ }
12924
+ return rows.length;
12925
+ }
12926
+ function upsertSqlite(db, table, columns, rows) {
12927
+ if (columns.length === 0)
12928
+ return 0;
12929
+ const primaryKeys = PRIMARY_KEYS[table];
12930
+ const columnList = columns.map(quoteIdent).join(", ");
12931
+ const placeholders = columns.map(() => "?").join(", ");
12932
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
12933
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
12934
+ const fallbackKey = primaryKeys[0];
12935
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
12936
+ const whereClause = conflictWhereClause(table, columns, "excluded");
12937
+ const statement = db.prepare(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`);
12938
+ for (const row of rows) {
12939
+ const values = columns.map((column) => normalizeSqliteSyncValue(row[column]));
12940
+ statement.run(...values);
12941
+ }
12942
+ return rows.length;
12943
+ }
12944
+ function conflictWhereClause(table, columns, incomingAlias) {
12945
+ const timestampColumn = columns.includes("updated_at") ? "updated_at" : columns.includes("created_at") ? "created_at" : null;
12946
+ if (!timestampColumn)
12947
+ return "";
12948
+ const existing = `${quoteIdent(table)}.${quoteIdent(timestampColumn)}`;
12949
+ const incoming = `${incomingAlias}.${quoteIdent(timestampColumn)}`;
12950
+ return ` WHERE ${existing} IS NULL OR ${incoming} IS NULL OR ${incoming} >= ${existing}`;
12951
+ }
12952
+ function toPostgresValue(value, dataType) {
12953
+ if (value === undefined)
12954
+ return null;
12955
+ if (dataType === "boolean") {
12956
+ if (value === null)
12957
+ return null;
12958
+ if (typeof value === "number")
12959
+ return value !== 0;
12960
+ if (typeof value === "string")
12961
+ return !["0", "false", "no", ""].includes(value.toLowerCase());
12962
+ return Boolean(value);
12963
+ }
12964
+ return value;
12965
+ }
12966
+ function normalizeSqliteSyncValue(value) {
12967
+ if (value === undefined || value === null)
12968
+ return null;
12969
+ if (value instanceof Date)
12970
+ return value.toISOString();
12971
+ if (typeof value === "boolean")
12972
+ return value ? 1 : 0;
12973
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)
12974
+ return value;
12975
+ if (typeof value === "object")
12976
+ return JSON.stringify(value);
12977
+ return value;
12978
+ }
12979
+ function ensureSyncMetaTable(db) {
12980
+ db.exec(`
12981
+ CREATE TABLE IF NOT EXISTS _contacts_sync_meta (
12982
+ table_name TEXT NOT NULL,
12983
+ direction TEXT NOT NULL CHECK(direction IN ('push','pull')),
12984
+ last_synced_at TEXT NOT NULL,
12985
+ PRIMARY KEY (table_name, direction)
12986
+ )
12987
+ `);
12988
+ }
12989
+ function recordSyncMeta(db, direction, results) {
12990
+ ensureSyncMetaTable(db);
12991
+ const now3 = new Date().toISOString();
12992
+ const statement = db.prepare(`
12993
+ INSERT INTO _contacts_sync_meta (table_name, direction, last_synced_at)
12994
+ VALUES (?, ?, ?)
12995
+ ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
12996
+ `);
12997
+ for (const result of results) {
12998
+ if (result.errors.length === 0)
12999
+ statement.run(result.table, direction, now3);
13000
+ }
13001
+ }
13002
+ function throwIfSyncErrors(operation, results) {
13003
+ const failures = results.filter((result) => result.errors.length > 0);
13004
+ if (failures.length === 0)
13005
+ return;
13006
+ const details = failures.map((result) => `${result.table}: ${result.errors.join("; ")}`).join("; ");
13007
+ throw new ContactsRemoteSyncError(`Contacts remote ${operation} failed for ${failures.length} table(s): ${details}`, results);
13008
+ }
13009
+ function quoteIdent(identifier) {
13010
+ return `"${identifier.replaceAll('"', '""')}"`;
13011
+ }
13012
+
13013
+ // src/mcp/storage-tools.ts
13014
+ function ok(data) {
13015
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
13016
+ }
13017
+ function err(error) {
13018
+ return {
13019
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
13020
+ isError: true
13021
+ };
13022
+ }
13023
+ function cloudStatus() {
13024
+ const remote = getRemoteStatus();
13025
+ return {
13026
+ service: "contacts",
13027
+ mode: "local-first",
13028
+ remote_sync: {
13029
+ configured: Boolean(getRemoteDatabaseUrl()),
13030
+ env: CONTACTS_REMOTE_ENV,
13031
+ default_tables: remote.default_tables,
13032
+ sensitive_tables: remote.sensitive_tables,
13033
+ tables: CONTACTS_REMOTE_TABLES,
13034
+ sync: remote.sync,
13035
+ reason: getRemoteDatabaseUrl() ? "Contacts uses repo-owned PostgreSQL sync; the deprecated shared cloud runtime is not used." : "Set a contacts-owned PostgreSQL URL to enable sync; the deprecated shared cloud runtime is not used."
13036
+ },
13037
+ storage: getStorageStatus()
13038
+ };
13039
+ }
13040
+ function parseTables(tables) {
13041
+ if (!tables)
13042
+ return;
13043
+ return tables.split(",").map((table) => table.trim()).filter(Boolean);
13044
+ }
13045
+ function registerContactsStorageTools(server) {
13046
+ server.tool("contacts_storage_status", "Show contacts-owned local database storage status", {}, async () => {
13047
+ try {
13048
+ return ok({ mode: "local-first", local: getStorageStatus(), remote: getRemoteStatus() });
13049
+ } catch (error) {
13050
+ return err(error);
13051
+ }
13052
+ });
13053
+ server.tool("contacts_storage_push", "Push local contacts data to contacts-owned PostgreSQL storage", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
13054
+ try {
13055
+ return ok(await pushRemote({ tables: parseTables(tables) }));
13056
+ } catch (error) {
13057
+ return err(error);
13058
+ }
13059
+ });
13060
+ server.tool("contacts_storage_pull", "Pull contacts data from contacts-owned PostgreSQL storage", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
13061
+ try {
13062
+ return ok(await pullRemote({ tables: parseTables(tables) }));
13063
+ } catch (error) {
13064
+ return err(error);
13065
+ }
13066
+ });
13067
+ server.tool("contacts_storage_sync", "Bidirectional contacts sync: pull then push", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
13068
+ try {
13069
+ return ok(await syncRemote({ tables: parseTables(tables) }));
13070
+ } catch (error) {
13071
+ return err(error);
13072
+ }
13073
+ });
13074
+ server.tool("contacts_cloud_status", "Compatibility status for contacts-owned storage and remote sync availability", {}, async () => {
13075
+ try {
13076
+ return ok(cloudStatus());
13077
+ } catch (error) {
13078
+ return err(error);
13079
+ }
13080
+ });
13081
+ server.tool("contacts_cloud_push", "Compatibility alias for contacts_storage_push", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
13082
+ try {
13083
+ return ok(await pushRemote({ tables: parseTables(tables) }));
13084
+ } catch (error) {
13085
+ return err(error);
13086
+ }
13087
+ });
13088
+ server.tool("contacts_cloud_pull", "Compatibility alias for contacts_storage_pull", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
13089
+ try {
13090
+ return ok(await pullRemote({ tables: parseTables(tables) }));
13091
+ } catch (error) {
13092
+ return err(error);
13093
+ }
13094
+ });
13095
+ server.tool("contacts_cloud_sync", "Compatibility alias for contacts_storage_sync", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
13096
+ try {
13097
+ return ok(await syncRemote({ tables: parseTables(tables) }));
13098
+ } catch (error) {
13099
+ return err(error);
13100
+ }
13101
+ });
12089
13102
  server.tool("contacts_cloud_feedback", "Save contacts feedback locally", {
12090
13103
  message: exports_external.string().describe("Feedback message"),
12091
13104
  email: exports_external.string().optional().describe("Contact email")