@hasna/contacts 0.6.34 → 0.6.36

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 (214) hide show
  1. package/README.md +10 -1
  2. package/dashboard/dist/assets/index-0l6aQb1t.css +1 -0
  3. package/dashboard/dist/assets/index-opnZdkVD.js +229 -0
  4. package/dashboard/dist/index.html +13 -0
  5. package/dist/cli/commands/advanced.d.ts +3 -0
  6. package/dist/cli/commands/advanced.d.ts.map +1 -0
  7. package/dist/cli/commands/audience.d.ts +18 -0
  8. package/dist/cli/commands/audience.d.ts.map +1 -0
  9. package/dist/cli/commands/core.d.ts +3 -0
  10. package/dist/cli/commands/core.d.ts.map +1 -0
  11. package/dist/cli/commands/crm.d.ts +3 -0
  12. package/dist/cli/commands/crm.d.ts.map +1 -0
  13. package/dist/cli/index.d.ts +3 -0
  14. package/dist/cli/index.d.ts.map +1 -0
  15. package/dist/cli/index.js +2994 -579
  16. package/dist/cli/storage.d.ts +3 -0
  17. package/dist/cli/storage.d.ts.map +1 -0
  18. package/dist/cli/utils.d.ts +10 -0
  19. package/dist/cli/utils.d.ts.map +1 -0
  20. package/dist/cloud/http-storage.d.ts +88 -0
  21. package/dist/cloud/http-storage.d.ts.map +1 -0
  22. package/dist/db/activity.d.ts +15 -0
  23. package/dist/db/activity.d.ts.map +1 -0
  24. package/dist/db/applications.d.ts +10 -0
  25. package/dist/db/applications.d.ts.map +1 -0
  26. package/dist/db/audiences.d.ts +51 -0
  27. package/dist/db/audiences.d.ts.map +1 -0
  28. package/dist/db/companies.d.ts +19 -0
  29. package/dist/db/companies.d.ts.map +1 -0
  30. package/dist/db/contact-tasks.d.ts +19 -0
  31. package/dist/db/contact-tasks.d.ts.map +1 -0
  32. package/dist/db/contacts.d.ts +30 -0
  33. package/dist/db/contacts.d.ts.map +1 -0
  34. package/dist/db/coordination.d.ts +30 -0
  35. package/dist/db/coordination.d.ts.map +1 -0
  36. package/dist/db/database.d.ts +8 -0
  37. package/dist/db/database.d.ts.map +1 -0
  38. package/dist/db/deals.d.ts +14 -0
  39. package/dist/db/deals.d.ts.map +1 -0
  40. package/dist/db/documents.d.ts +38 -0
  41. package/dist/db/documents.d.ts.map +1 -0
  42. package/dist/db/events.d.ts +14 -0
  43. package/dist/db/events.d.ts.map +1 -0
  44. package/dist/db/field-history.d.ts +17 -0
  45. package/dist/db/field-history.d.ts.map +1 -0
  46. package/dist/db/freshness.d.ts +24 -0
  47. package/dist/db/freshness.d.ts.map +1 -0
  48. package/dist/db/graph.d.ts +21 -0
  49. package/dist/db/graph.d.ts.map +1 -0
  50. package/dist/db/groups.d.ts +22 -0
  51. package/dist/db/groups.d.ts.map +1 -0
  52. package/dist/db/health.d.ts +40 -0
  53. package/dist/db/health.d.ts.map +1 -0
  54. package/dist/db/identity.d.ts +32 -0
  55. package/dist/db/identity.d.ts.map +1 -0
  56. package/dist/db/job-history.d.ts +29 -0
  57. package/dist/db/job-history.d.ts.map +1 -0
  58. package/dist/db/learnings.d.ts +43 -0
  59. package/dist/db/learnings.d.ts.map +1 -0
  60. package/dist/db/notes.d.ts +8 -0
  61. package/dist/db/notes.d.ts.map +1 -0
  62. package/dist/db/org-chart.d.ts +37 -0
  63. package/dist/db/org-chart.d.ts.map +1 -0
  64. package/dist/db/org-members.d.ts +9 -0
  65. package/dist/db/org-members.d.ts.map +1 -0
  66. package/dist/db/paths.d.ts +15 -0
  67. package/dist/db/paths.d.ts.map +1 -0
  68. package/dist/db/pg-migrations.d.ts +8 -0
  69. package/dist/db/pg-migrations.d.ts.map +1 -0
  70. package/dist/db/project-memberships.d.ts +8 -0
  71. package/dist/db/project-memberships.d.ts.map +1 -0
  72. package/dist/db/relationships.d.ts +30 -0
  73. package/dist/db/relationships.d.ts.map +1 -0
  74. package/dist/db/signals.d.ts +18 -0
  75. package/dist/db/signals.d.ts.map +1 -0
  76. package/dist/db/sqlite-adapter.d.ts +15 -0
  77. package/dist/db/sqlite-adapter.d.ts.map +1 -0
  78. package/dist/db/storage.d.ts +15 -0
  79. package/dist/db/storage.d.ts.map +1 -0
  80. package/dist/db/tags.d.ts +14 -0
  81. package/dist/db/tags.d.ts.map +1 -0
  82. package/dist/db/tombstones.d.ts +19 -0
  83. package/dist/db/tombstones.d.ts.map +1 -0
  84. package/dist/db/vendor-comms.d.ts +15 -0
  85. package/dist/db/vendor-comms.d.ts.map +1 -0
  86. package/dist/generated/storage-kit/health.d.ts +20 -0
  87. package/dist/generated/storage-kit/health.d.ts.map +1 -0
  88. package/dist/generated/storage-kit/index.d.ts +8 -0
  89. package/dist/generated/storage-kit/index.d.ts.map +1 -0
  90. package/dist/generated/storage-kit/migrations.d.ts +48 -0
  91. package/dist/generated/storage-kit/migrations.d.ts.map +1 -0
  92. package/dist/generated/storage-kit/mode.d.ts +48 -0
  93. package/dist/generated/storage-kit/mode.d.ts.map +1 -0
  94. package/dist/generated/storage-kit/pool.d.ts +34 -0
  95. package/dist/generated/storage-kit/pool.d.ts.map +1 -0
  96. package/dist/generated/storage-kit/query.d.ts +36 -0
  97. package/dist/generated/storage-kit/query.d.ts.map +1 -0
  98. package/dist/generated/storage-kit/tls.d.ts +26 -0
  99. package/dist/generated/storage-kit/tls.d.ts.map +1 -0
  100. package/dist/index.d.ts +9 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +805 -75
  103. package/dist/lib/apple-contacts.d.ts +3 -0
  104. package/dist/lib/apple-contacts.d.ts.map +1 -0
  105. package/dist/lib/audience-contract.d.ts +215 -0
  106. package/dist/lib/audience-contract.d.ts.map +1 -0
  107. package/dist/lib/audit.d.ts +12 -0
  108. package/dist/lib/audit.d.ts.map +1 -0
  109. package/dist/lib/brief.d.ts +3 -0
  110. package/dist/lib/brief.d.ts.map +1 -0
  111. package/dist/lib/config.d.ts +7 -0
  112. package/dist/lib/config.d.ts.map +1 -0
  113. package/dist/lib/connector.d.ts +44 -0
  114. package/dist/lib/connector.d.ts.map +1 -0
  115. package/dist/lib/context.d.ts +5 -0
  116. package/dist/lib/context.d.ts.map +1 -0
  117. package/dist/lib/dedup.d.ts +10 -0
  118. package/dist/lib/dedup.d.ts.map +1 -0
  119. package/dist/lib/document-scanner.d.ts +8 -0
  120. package/dist/lib/document-scanner.d.ts.map +1 -0
  121. package/dist/lib/embeddings.d.ts +9 -0
  122. package/dist/lib/embeddings.d.ts.map +1 -0
  123. package/dist/lib/export.d.ts +8 -0
  124. package/dist/lib/export.d.ts.map +1 -0
  125. package/dist/lib/freshness.d.ts +19 -0
  126. package/dist/lib/freshness.d.ts.map +1 -0
  127. package/dist/lib/gmail-import.d.ts +51 -0
  128. package/dist/lib/gmail-import.d.ts.map +1 -0
  129. package/dist/lib/google-contacts.d.ts +115 -0
  130. package/dist/lib/google-contacts.d.ts.map +1 -0
  131. package/dist/lib/images.d.ts +30 -0
  132. package/dist/lib/images.d.ts.map +1 -0
  133. package/dist/lib/import.d.ts +10 -0
  134. package/dist/lib/import.d.ts.map +1 -0
  135. package/dist/lib/learning-maintenance.d.ts +8 -0
  136. package/dist/lib/learning-maintenance.d.ts.map +1 -0
  137. package/dist/lib/mailery-sync.d.ts +46 -0
  138. package/dist/lib/mailery-sync.d.ts.map +1 -0
  139. package/dist/lib/meeting-capture.d.ts +15 -0
  140. package/dist/lib/meeting-capture.d.ts.map +1 -0
  141. package/dist/lib/package-version.d.ts +6 -0
  142. package/dist/lib/package-version.d.ts.map +1 -0
  143. package/dist/lib/signals.d.ts +9 -0
  144. package/dist/lib/signals.d.ts.map +1 -0
  145. package/dist/lib/signature-parser.d.ts +36 -0
  146. package/dist/lib/signature-parser.d.ts.map +1 -0
  147. package/dist/lib/stats.d.ts +24 -0
  148. package/dist/lib/stats.d.ts.map +1 -0
  149. package/dist/lib/timeline.d.ts +11 -0
  150. package/dist/lib/timeline.d.ts.map +1 -0
  151. package/dist/lib/upcoming.d.ts +14 -0
  152. package/dist/lib/upcoming.d.ts.map +1 -0
  153. package/dist/lib/vault.d.ts +28 -0
  154. package/dist/lib/vault.d.ts.map +1 -0
  155. package/dist/mcp/handlers/advanced.d.ts +14 -0
  156. package/dist/mcp/handlers/advanced.d.ts.map +1 -0
  157. package/dist/mcp/handlers/audience.d.ts +9 -0
  158. package/dist/mcp/handlers/audience.d.ts.map +1 -0
  159. package/dist/mcp/handlers/core.d.ts +10 -0
  160. package/dist/mcp/handlers/core.d.ts.map +1 -0
  161. package/dist/mcp/handlers/crm.d.ts +14 -0
  162. package/dist/mcp/handlers/crm.d.ts.map +1 -0
  163. package/dist/mcp/handlers/index.d.ts +4 -0
  164. package/dist/mcp/handlers/index.d.ts.map +1 -0
  165. package/dist/mcp/handlers/types.d.ts +10 -0
  166. package/dist/mcp/handlers/types.d.ts.map +1 -0
  167. package/dist/mcp/http.d.ts +17 -0
  168. package/dist/mcp/http.d.ts.map +1 -0
  169. package/dist/mcp/index.d.ts +5 -0
  170. package/dist/mcp/index.d.ts.map +1 -0
  171. package/dist/mcp/index.js +1054 -291
  172. package/dist/mcp/register-tools.d.ts +3 -0
  173. package/dist/mcp/register-tools.d.ts.map +1 -0
  174. package/dist/mcp/schema.d.ts +3 -0
  175. package/dist/mcp/schema.d.ts.map +1 -0
  176. package/dist/mcp/storage-tools.d.ts +3 -0
  177. package/dist/mcp/storage-tools.d.ts.map +1 -0
  178. package/dist/mcp/tools.d.ts +3799 -0
  179. package/dist/mcp/tools.d.ts.map +1 -0
  180. package/dist/sdk/index.d.ts +16 -0
  181. package/dist/sdk/index.d.ts.map +1 -0
  182. package/dist/sdk/index.js +63 -0
  183. package/dist/sdk/v1.generated.d.ts +270 -0
  184. package/dist/sdk/v1.generated.d.ts.map +1 -0
  185. package/dist/server/cloud-index.d.ts +3 -0
  186. package/dist/server/cloud-index.d.ts.map +1 -0
  187. package/dist/server/cloud-serve.d.ts +3 -0
  188. package/dist/server/cloud-serve.d.ts.map +1 -0
  189. package/dist/server/cloud.d.ts +53 -0
  190. package/dist/server/cloud.d.ts.map +1 -0
  191. package/dist/server/index.d.ts +3 -0
  192. package/dist/server/index.d.ts.map +1 -0
  193. package/dist/server/index.js +1750 -332
  194. package/dist/server/openapi.d.ts +1134 -0
  195. package/dist/server/openapi.d.ts.map +1 -0
  196. package/dist/server/pg-store.d.ts +893 -0
  197. package/dist/server/pg-store.d.ts.map +1 -0
  198. package/dist/server/security.d.ts +24 -0
  199. package/dist/server/security.d.ts.map +1 -0
  200. package/dist/server/serve.d.ts +9 -0
  201. package/dist/server/serve.d.ts.map +1 -0
  202. package/dist/server/v1.d.ts +28 -0
  203. package/dist/server/v1.d.ts.map +1 -0
  204. package/dist/store/index.d.ts +387 -0
  205. package/dist/store/index.d.ts.map +1 -0
  206. package/dist/types/index.d.ts +856 -0
  207. package/dist/types/index.d.ts.map +1 -0
  208. package/dist/types/project-memberships.d.ts +32 -0
  209. package/dist/types/project-memberships.d.ts.map +1 -0
  210. package/hasna.contract.json +1 -1
  211. package/migrations/0015_contact_project_membership_authority.sql +88 -0
  212. package/package.json +4 -2
  213. package/vendor/fast-uri/README.md +12 -0
  214. package/dist/server/cloud-index.js +0 -9187
package/dist/index.js CHANGED
@@ -55,43 +55,169 @@ class SqliteAdapter {
55
55
  }
56
56
  var init_sqlite_adapter = () => {};
57
57
 
58
- // src/db/paths.ts
59
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
58
+ // node_modules/.pnpm/@hasna+paths@0.2.2/node_modules/@hasna/paths/dist/index.js
59
+ import { homedir } from "os";
60
60
  import { join } from "path";
61
+ function assertApp(app) {
62
+ if (typeof app !== "string" || app.length === 0) {
63
+ throw new TypeError("paths: app must be a non-empty string");
64
+ }
65
+ if (!APP_SLUG_RE.test(app)) {
66
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
67
+ }
68
+ }
69
+ function assertKind(kind) {
70
+ if (!PATH_KINDS.includes(kind)) {
71
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${PATH_KINDS.join(", ")}`);
72
+ }
73
+ }
74
+ function envOf(options) {
75
+ return options.env ?? process.env;
76
+ }
77
+ function envValue(options, kind) {
78
+ const value = envOf(options)[KIND_ENV[kind]];
79
+ return typeof value === "string" && value.length > 0 ? value : undefined;
80
+ }
81
+ function isMacOS(platform) {
82
+ return platform === "darwin";
83
+ }
84
+ function baseDir(kind, options) {
85
+ assertKind(kind);
86
+ const override = envValue(options, kind);
87
+ if (override)
88
+ return override;
89
+ const home = options.home ?? homedir();
90
+ const platform = options.platform ?? process.platform;
91
+ if (isMacOS(platform)) {
92
+ switch (kind) {
93
+ case "config":
94
+ case "data":
95
+ return join(home, "Library", "Application Support", "Hasna");
96
+ case "cache":
97
+ return join(home, "Library", "Caches", "Hasna");
98
+ case "state":
99
+ return join(home, "Library", "Logs", "Hasna");
100
+ }
101
+ }
102
+ switch (kind) {
103
+ case "config":
104
+ return join(home, ".config", "hasna");
105
+ case "data":
106
+ return join(home, ".local", "share", "hasna");
107
+ case "state":
108
+ return join(home, ".local", "state", "hasna");
109
+ case "cache":
110
+ return join(home, ".cache", "hasna");
111
+ }
112
+ }
113
+ function resolvePath(kind, options) {
114
+ assertKind(kind);
115
+ assertApp(options.app);
116
+ const appSegment = options.internal === true ? join("internal", options.app) : options.app;
117
+ return join(baseDir(kind, options), appSegment);
118
+ }
119
+ function dataDir(options) {
120
+ return resolvePath("data", options);
121
+ }
122
+ function stateDir(options) {
123
+ return resolvePath("state", options);
124
+ }
125
+ var PATH_KINDS, KIND_ENV, APP_SLUG_RE;
126
+ var init_dist = __esm(() => {
127
+ PATH_KINDS = ["config", "data", "state", "cache"];
128
+ KIND_ENV = {
129
+ config: "HASNA_CONFIG_HOME",
130
+ data: "HASNA_DATA_HOME",
131
+ state: "HASNA_STATE_HOME",
132
+ cache: "HASNA_CACHE_HOME"
133
+ };
134
+ APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
135
+ });
136
+
137
+ // src/db/paths.ts
138
+ import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
139
+ import { homedir as homedir2 } from "os";
140
+ import { join as join2 } from "path";
61
141
  function ensurePrivateDir(dir) {
62
142
  if (!existsSync(dir))
63
143
  mkdirSync(dir, { recursive: true, mode: 448 });
64
144
  chmodSync(dir, 448);
65
145
  }
146
+ function home() {
147
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
148
+ }
149
+ function hasContent(dir) {
150
+ if (!existsSync(dir))
151
+ return false;
152
+ try {
153
+ return readdirSync(dir).length > 0;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+ function adoptLegacy(source, target) {
159
+ if (!hasContent(source) || hasContent(target))
160
+ return;
161
+ ensurePrivateDir(target);
162
+ for (const entry of readdirSync(source)) {
163
+ if (entry === ".vault-session")
164
+ continue;
165
+ if (entry === "{backups,images,documents}")
166
+ continue;
167
+ const oldPath = join2(source, entry);
168
+ const newPath = join2(target, entry);
169
+ const st = statSync(oldPath);
170
+ if (st.isDirectory()) {
171
+ cpSync(oldPath, newPath, { recursive: true });
172
+ chmodSync(newPath, 448);
173
+ } else if (st.isFile()) {
174
+ copyFileSync(oldPath, newPath);
175
+ chmodSync(newPath, 384);
176
+ }
177
+ }
178
+ }
66
179
  function getDataDir() {
67
- const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
68
- const hasnaDir = join(home, ".hasna");
69
- const newDir = join(home, ".hasna", "contacts");
70
- const oldDir = join(home, ".contacts");
71
- if (existsSync(oldDir) && !existsSync(newDir)) {
72
- ensurePrivateDir(hasnaDir);
73
- ensurePrivateDir(newDir);
74
- for (const file of readdirSync(oldDir)) {
75
- const oldPath = join(oldDir, file);
76
- if (statSync(oldPath).isFile()) {
77
- const newPath = join(newDir, file);
78
- copyFileSync(oldPath, newPath);
79
- chmodSync(newPath, 384);
180
+ const base = home();
181
+ const target = dataDir({ app: "contacts", home: base });
182
+ if (!checkedDataTargets.has(target)) {
183
+ adoptLegacy(join2(base, ".hasna", "contacts"), target);
184
+ adoptLegacy(join2(base, ".contacts"), target);
185
+ checkedDataTargets.add(target);
186
+ }
187
+ ensurePrivateDir(target);
188
+ return target;
189
+ }
190
+ function getStateDir() {
191
+ const base = home();
192
+ const target = stateDir({ app: "contacts", home: base });
193
+ if (!checkedStateTargets.has(target)) {
194
+ if (!hasContent(target)) {
195
+ const legacySession = join2(base, ".hasna", "contacts", ".vault-session");
196
+ if (existsSync(legacySession)) {
197
+ ensurePrivateDir(target);
198
+ const targetSession = join2(target, ".vault-session");
199
+ copyFileSync(legacySession, targetSession);
200
+ chmodSync(targetSession, 384);
80
201
  }
81
202
  }
203
+ checkedStateTargets.add(target);
82
204
  }
83
- ensurePrivateDir(hasnaDir);
84
- ensurePrivateDir(newDir);
85
- return newDir;
205
+ ensurePrivateDir(target);
206
+ return target;
86
207
  }
87
208
  function getDbPath() {
88
209
  if (process.env["HASNA_CONTACTS_DB_PATH"])
89
210
  return process.env["HASNA_CONTACTS_DB_PATH"];
90
211
  if (process.env["CONTACTS_DB_PATH"])
91
212
  return process.env["CONTACTS_DB_PATH"];
92
- return join(getDataDir(), "contacts.db");
213
+ return join2(getDataDir(), "contacts.db");
93
214
  }
94
- var init_paths = () => {};
215
+ var checkedDataTargets, checkedStateTargets;
216
+ var init_paths = __esm(() => {
217
+ init_dist();
218
+ checkedDataTargets = new Set;
219
+ checkedStateTargets = new Set;
220
+ });
95
221
 
96
222
  // src/db/database.ts
97
223
  var exports_database = {};
@@ -99,6 +225,7 @@ __export(exports_database, {
99
225
  uuid: () => uuid,
100
226
  resetDatabase: () => resetDatabase,
101
227
  now: () => now,
228
+ getStateDir: () => getStateDir,
102
229
  getDbPath: () => getDbPath,
103
230
  getDatabase: () => getDatabase,
104
231
  getDataDir: () => getDataDir
@@ -144,7 +271,10 @@ function uuid() {
144
271
  return crypto.randomUUID();
145
272
  }
146
273
  function now() {
147
- return new Date().toISOString();
274
+ const currentMs = Date.now();
275
+ const nextMs = currentMs > lastNowMs ? currentMs : lastNowMs + 1;
276
+ lastNowMs = nextMs;
277
+ return new Date(nextMs).toISOString();
148
278
  }
149
279
  function quoteIdentifier(identifier) {
150
280
  return `"${identifier.replaceAll('"', '""')}"`;
@@ -182,7 +312,7 @@ function runMigrations(db) {
182
312
  db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${i})`);
183
313
  }
184
314
  }
185
- var MIGRATIONS, _db = null;
315
+ var MIGRATIONS, _db = null, lastNowMs = 0;
186
316
  var init_database = __esm(() => {
187
317
  init_sqlite_adapter();
188
318
  init_paths();
@@ -754,6 +884,81 @@ var init_database = __esm(() => {
754
884
  );
755
885
 
756
886
  CREATE INDEX IF NOT EXISTS idx_contacts_tombstones_deleted_at ON _contacts_tombstones(deleted_at);
887
+ `,
888
+ `
889
+ CREATE TABLE IF NOT EXISTS contact_project_membership_states (
890
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
891
+ project_id TEXT NOT NULL,
892
+ linked INTEGER NOT NULL CHECK(linked IN (0, 1)),
893
+ revision INTEGER NOT NULL DEFAULT 0 CHECK(revision >= 0),
894
+ updated_at TEXT NOT NULL,
895
+ PRIMARY KEY (contact_id, project_id)
896
+ );
897
+
898
+ INSERT OR IGNORE INTO contact_project_membership_states
899
+ (contact_id, project_id, linked, revision, updated_at)
900
+ SELECT contact_id, project_id, 1, 0, datetime('now')
901
+ FROM contact_projects;
902
+
903
+ CREATE TABLE IF NOT EXISTS contact_project_membership_receipts (
904
+ receipt_id TEXT PRIMARY KEY,
905
+ direction TEXT NOT NULL CHECK(direction IN ('attach', 'detach')),
906
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
907
+ project_id TEXT NOT NULL,
908
+ operation_id TEXT NOT NULL,
909
+ step_id TEXT NOT NULL,
910
+ expected_version TEXT NOT NULL,
911
+ before_json TEXT NOT NULL,
912
+ after_json TEXT NOT NULL,
913
+ created_at TEXT NOT NULL,
914
+ UNIQUE(operation_id, step_id)
915
+ );
916
+
917
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_states_project
918
+ ON contact_project_membership_states(project_id, linked, contact_id);
919
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_receipts_target
920
+ ON contact_project_membership_receipts(contact_id, project_id, created_at);
921
+
922
+ CREATE TRIGGER IF NOT EXISTS sync_contact_project_membership_state_after_insert
923
+ AFTER INSERT ON contact_projects
924
+ BEGIN
925
+ INSERT INTO contact_project_membership_states
926
+ (contact_id, project_id, linked, revision, updated_at)
927
+ VALUES (NEW.contact_id, NEW.project_id, 1, 1, datetime('now'))
928
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
929
+ linked = 1,
930
+ revision = CASE
931
+ WHEN contact_project_membership_states.linked = 1
932
+ THEN contact_project_membership_states.revision
933
+ ELSE contact_project_membership_states.revision + 1
934
+ END,
935
+ updated_at = CASE
936
+ WHEN contact_project_membership_states.linked = 1
937
+ THEN contact_project_membership_states.updated_at
938
+ ELSE datetime('now')
939
+ END;
940
+ END;
941
+
942
+ CREATE TRIGGER IF NOT EXISTS sync_contact_project_membership_state_after_delete
943
+ AFTER DELETE ON contact_projects
944
+ WHEN EXISTS (SELECT 1 FROM contacts WHERE id = OLD.contact_id)
945
+ BEGIN
946
+ INSERT INTO contact_project_membership_states
947
+ (contact_id, project_id, linked, revision, updated_at)
948
+ VALUES (OLD.contact_id, OLD.project_id, 0, 1, datetime('now'))
949
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
950
+ linked = 0,
951
+ revision = CASE
952
+ WHEN contact_project_membership_states.linked = 0
953
+ THEN contact_project_membership_states.revision
954
+ ELSE contact_project_membership_states.revision + 1
955
+ END,
956
+ updated_at = CASE
957
+ WHEN contact_project_membership_states.linked = 0
958
+ THEN contact_project_membership_states.updated_at
959
+ ELSE datetime('now')
960
+ END;
961
+ END;
757
962
  `
758
963
  ];
759
964
  });
@@ -853,6 +1058,219 @@ var init_tombstones = __esm(() => {
853
1058
  init_database();
854
1059
  });
855
1060
 
1061
+ // src/types/project-memberships.ts
1062
+ var ContactProjectMembershipConflictError;
1063
+ var init_project_memberships = __esm(() => {
1064
+ ContactProjectMembershipConflictError = class ContactProjectMembershipConflictError extends Error {
1065
+ constructor(message) {
1066
+ super(message);
1067
+ this.name = "ContactProjectMembershipConflictError";
1068
+ }
1069
+ };
1070
+ });
1071
+
1072
+ // src/db/project-memberships.ts
1073
+ import { createHash } from "crypto";
1074
+ function digest(value) {
1075
+ return createHash("sha256").update(value).digest("hex");
1076
+ }
1077
+ function membershipVersion(contactId, projectId, linked, revision) {
1078
+ return `cpmv_${digest(JSON.stringify([contactId, projectId, linked, revision])).slice(0, 32)}`;
1079
+ }
1080
+ function receiptId(input) {
1081
+ return `cpmr_${digest(JSON.stringify([
1082
+ input.operation_id,
1083
+ input.step_id,
1084
+ input.contact_id,
1085
+ input.project_id
1086
+ ])).slice(0, 32)}`;
1087
+ }
1088
+ function stateRow(contactId, projectId, db) {
1089
+ const persisted = db.query(`SELECT contact_id, project_id, linked, revision
1090
+ FROM contact_project_membership_states
1091
+ WHERE contact_id = ? AND project_id = ?`).get(contactId, projectId);
1092
+ if (persisted)
1093
+ return persisted;
1094
+ const linked = Boolean(db.query(`SELECT 1 AS present FROM contact_projects WHERE contact_id = ? AND project_id = ?`).get(contactId, projectId));
1095
+ return { contact_id: contactId, project_id: projectId, linked: linked ? 1 : 0, revision: 0 };
1096
+ }
1097
+ function snapshot(row) {
1098
+ const linked = Boolean(row.linked);
1099
+ return {
1100
+ contact_id: row.contact_id,
1101
+ project_id: row.project_id,
1102
+ linked,
1103
+ version: membershipVersion(row.contact_id, row.project_id, linked, row.revision)
1104
+ };
1105
+ }
1106
+ function required(value, name) {
1107
+ const normalized = value.trim();
1108
+ if (!normalized)
1109
+ throw new Error(`${name} is required`);
1110
+ return normalized;
1111
+ }
1112
+ function normalizeInput(input) {
1113
+ return {
1114
+ contact_id: required(input.contact_id, "contact_id"),
1115
+ project_id: required(input.project_id, "project_id"),
1116
+ operation_id: required(input.operation_id, "operation_id"),
1117
+ step_id: required(input.step_id, "step_id"),
1118
+ expected_version: required(input.expected_version, "expected_version")
1119
+ };
1120
+ }
1121
+ function parseSnapshot(value) {
1122
+ return JSON.parse(value);
1123
+ }
1124
+ function replay(row, direction, input) {
1125
+ if (row.direction !== direction || row.contact_id !== input.contact_id || row.project_id !== input.project_id || row.expected_version !== input.expected_version) {
1126
+ throw new ContactProjectMembershipConflictError(`operation_id/step_id already accepted for a different contact-project membership mutation`);
1127
+ }
1128
+ return {
1129
+ outcome: "duplicate_of_accepted",
1130
+ operation_id: row.operation_id,
1131
+ step_id: row.step_id,
1132
+ before: parseSnapshot(row.before_json),
1133
+ after: parseSnapshot(row.after_json),
1134
+ receipt_id: row.receipt_id
1135
+ };
1136
+ }
1137
+ function transitionWithoutReceipt(contactId, projectId, linked, db) {
1138
+ const beforeRow = stateRow(contactId, projectId, db);
1139
+ const changed = Boolean(beforeRow.linked) !== linked;
1140
+ const afterRow = {
1141
+ ...beforeRow,
1142
+ linked: linked ? 1 : 0,
1143
+ revision: changed ? beforeRow.revision + 1 : beforeRow.revision
1144
+ };
1145
+ const changedAt = now();
1146
+ db.run(`INSERT INTO contact_project_membership_states
1147
+ (contact_id, project_id, linked, revision, updated_at)
1148
+ VALUES (?, ?, ?, ?, ?)
1149
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
1150
+ linked = excluded.linked,
1151
+ revision = excluded.revision,
1152
+ updated_at = excluded.updated_at`, [contactId, projectId, afterRow.linked, afterRow.revision, changedAt]);
1153
+ if (linked) {
1154
+ db.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, projectId]);
1155
+ } else {
1156
+ db.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [contactId, projectId]);
1157
+ }
1158
+ return changed;
1159
+ }
1160
+ function setContactProjectMembershipWithoutReceipt(contactId, projectId, linked, db = getDatabase()) {
1161
+ const normalizedContactId = required(contactId, "contact_id");
1162
+ const normalizedProjectId = required(projectId, "project_id");
1163
+ return db.transaction(() => transitionWithoutReceipt(normalizedContactId, normalizedProjectId, linked, db));
1164
+ }
1165
+ function replaceContactProjectMembershipsWithoutReceipts(contactId, projectIds, db = getDatabase()) {
1166
+ const normalizedContactId = required(contactId, "contact_id");
1167
+ const normalizedProjectIds = [...new Set(projectIds.map((projectId) => required(projectId, "project_id")))];
1168
+ return db.transaction(() => {
1169
+ const currentRows = db.query(`SELECT project_id FROM contact_projects WHERE contact_id = ?`).all(normalizedContactId);
1170
+ const desired = new Set(normalizedProjectIds);
1171
+ const population = new Set([...currentRows.map((row) => row.project_id), ...normalizedProjectIds]);
1172
+ for (const projectId of population) {
1173
+ transitionWithoutReceipt(normalizedContactId, projectId, desired.has(projectId), db);
1174
+ }
1175
+ return normalizedProjectIds;
1176
+ });
1177
+ }
1178
+ function readContactProjectMembership(contactId, projectId, db = getDatabase()) {
1179
+ return snapshot(stateRow(required(contactId, "contact_id"), required(projectId, "project_id"), db));
1180
+ }
1181
+ function listContactProjectMemberships(projectId, maxItems, db = getDatabase()) {
1182
+ const normalizedProjectId = required(projectId, "project_id");
1183
+ if (!Number.isInteger(maxItems) || maxItems < 1)
1184
+ throw new Error("max_items must be a positive integer");
1185
+ const rows = db.query(`SELECT cp.contact_id, cp.project_id,
1186
+ COALESCE(state.linked, 1) AS linked,
1187
+ COALESCE(state.revision, 0) AS revision
1188
+ FROM contact_projects cp
1189
+ LEFT JOIN contact_project_membership_states state
1190
+ ON state.contact_id = cp.contact_id AND state.project_id = cp.project_id
1191
+ WHERE cp.project_id = ?
1192
+ ORDER BY cp.contact_id ASC
1193
+ LIMIT ?`).all(normalizedProjectId, maxItems + 1);
1194
+ if (rows.length > maxItems) {
1195
+ throw new Error(`contact project membership collection exceeds max_items=${maxItems}`);
1196
+ }
1197
+ const contactIds = rows.map((row) => row.contact_id);
1198
+ return {
1199
+ project_id: normalizedProjectId,
1200
+ contact_ids: contactIds,
1201
+ complete: true,
1202
+ membership_revision: `cpml_${digest(JSON.stringify(rows.map((row) => snapshot(row)))).slice(0, 32)}`
1203
+ };
1204
+ }
1205
+ function mutateContactProjectMembership(direction, rawInput, db = getDatabase()) {
1206
+ const input = normalizeInput(rawInput);
1207
+ return db.transaction(() => {
1208
+ const existingReceipt = db.query(`SELECT direction, contact_id, project_id, operation_id, step_id, expected_version,
1209
+ before_json, after_json, receipt_id
1210
+ FROM contact_project_membership_receipts
1211
+ WHERE operation_id = ? AND step_id = ?`).get(input.operation_id, input.step_id);
1212
+ if (existingReceipt)
1213
+ return replay(existingReceipt, direction, input);
1214
+ const contact = db.query(`SELECT id FROM contacts WHERE id = ?`).get(input.contact_id);
1215
+ if (!contact)
1216
+ throw new Error(`contact not found: ${input.contact_id}`);
1217
+ const beforeRow = stateRow(input.contact_id, input.project_id, db);
1218
+ const before = snapshot(beforeRow);
1219
+ if (before.version !== input.expected_version) {
1220
+ throw new ContactProjectMembershipConflictError(`contact project membership expected_version conflict: expected ${input.expected_version}, current ${before.version}`);
1221
+ }
1222
+ const desiredLinked = direction === "attach";
1223
+ const changed = before.linked !== desiredLinked;
1224
+ const afterRow = {
1225
+ ...beforeRow,
1226
+ linked: desiredLinked ? 1 : 0,
1227
+ revision: changed ? beforeRow.revision + 1 : beforeRow.revision
1228
+ };
1229
+ const changedAt = now();
1230
+ db.run(`INSERT INTO contact_project_membership_states
1231
+ (contact_id, project_id, linked, revision, updated_at)
1232
+ VALUES (?, ?, ?, ?, ?)
1233
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
1234
+ linked = excluded.linked,
1235
+ revision = excluded.revision,
1236
+ updated_at = excluded.updated_at`, [input.contact_id, input.project_id, afterRow.linked, afterRow.revision, changedAt]);
1237
+ if (desiredLinked) {
1238
+ db.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [input.contact_id, input.project_id]);
1239
+ } else {
1240
+ db.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [input.contact_id, input.project_id]);
1241
+ }
1242
+ const after = snapshot(afterRow);
1243
+ const id = receiptId(input);
1244
+ db.run(`INSERT INTO contact_project_membership_receipts (
1245
+ receipt_id, direction, contact_id, project_id, operation_id, step_id,
1246
+ expected_version, before_json, after_json, created_at
1247
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1248
+ id,
1249
+ direction,
1250
+ input.contact_id,
1251
+ input.project_id,
1252
+ input.operation_id,
1253
+ input.step_id,
1254
+ input.expected_version,
1255
+ JSON.stringify(before),
1256
+ JSON.stringify(after),
1257
+ changedAt
1258
+ ]);
1259
+ return {
1260
+ outcome: changed ? "accepted" : "duplicate_of_accepted",
1261
+ operation_id: input.operation_id,
1262
+ step_id: input.step_id,
1263
+ before,
1264
+ after,
1265
+ receipt_id: id
1266
+ };
1267
+ });
1268
+ }
1269
+ var init_project_memberships2 = __esm(() => {
1270
+ init_database();
1271
+ init_project_memberships();
1272
+ });
1273
+
856
1274
  // src/db/contacts.ts
857
1275
  var exports_contacts = {};
858
1276
  __export(exports_contacts, {
@@ -1451,34 +1869,32 @@ function autoLinkContactToCompany(contactId, db) {
1451
1869
  }
1452
1870
  function linkContactToProject(contactId, projectId, db) {
1453
1871
  const d = db || getDatabase();
1454
- d.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, projectId]);
1872
+ setContactProjectMembershipWithoutReceipt(contactId, projectId, true, d);
1455
1873
  }
1456
1874
  function unlinkContactFromProject(contactId, projectId, db) {
1457
1875
  const d = db || getDatabase();
1458
- d.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [contactId, projectId]);
1876
+ setContactProjectMembershipWithoutReceipt(contactId, projectId, false, d);
1459
1877
  }
1460
1878
  function getContactProjectIds(contactId, db) {
1461
1879
  const d = db || getDatabase();
1462
- const rows = d.query(`SELECT project_id FROM contact_projects WHERE contact_id = ?`).all(contactId);
1880
+ const rows = d.query(`SELECT project_id FROM contact_projects WHERE contact_id = ? ORDER BY project_id ASC`).all(contactId);
1463
1881
  return rows.map((r) => r.project_id);
1464
1882
  }
1465
1883
  function listContactIdsByProject(projectId, db) {
1466
1884
  const d = db || getDatabase();
1467
- const rows = d.query(`SELECT contact_id FROM contact_projects WHERE project_id = ?`).all(projectId);
1885
+ const rows = d.query(`SELECT contact_id FROM contact_projects WHERE project_id = ? ORDER BY contact_id ASC`).all(projectId);
1468
1886
  return rows.map((r) => r.contact_id);
1469
1887
  }
1470
1888
  function setContactProjects(contactId, projectIds, db) {
1471
1889
  const d = db || getDatabase();
1472
- d.run(`DELETE FROM contact_projects WHERE contact_id = ?`, [contactId]);
1473
- for (const pid of projectIds) {
1474
- d.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, pid]);
1475
- }
1890
+ replaceContactProjectMembershipsWithoutReceipts(contactId, projectIds, d);
1476
1891
  }
1477
1892
  var init_contacts = __esm(() => {
1478
1893
  init_types();
1479
1894
  init_database();
1480
1895
  init_activity();
1481
1896
  init_tombstones();
1897
+ init_project_memberships2();
1482
1898
  });
1483
1899
 
1484
1900
  // src/db/events.ts
@@ -1644,6 +2060,7 @@ function getStorageStatus(db = getDatabase()) {
1644
2060
 
1645
2061
  // src/store/index.ts
1646
2062
  init_contacts();
2063
+ init_project_memberships2();
1647
2064
 
1648
2065
  // src/db/companies.ts
1649
2066
  init_types();
@@ -3172,13 +3589,24 @@ init_database();
3172
3589
  // src/lib/vault.ts
3173
3590
  init_database();
3174
3591
  import { existsSync as existsSync3, readFileSync, writeFileSync, mkdirSync as mkdirSync3, unlinkSync } from "fs";
3175
- import { join as join2 } from "path";
3176
- import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto";
3177
- var VAULT_DIR = getDataDir();
3178
- var VAULT_CONFIG = join2(VAULT_DIR, "vault.json");
3179
- var VAULT_SESSION = join2(VAULT_DIR, ".vault-session");
3180
- var DOCUMENTS_DIR = join2(VAULT_DIR, "documents");
3592
+ import { join as join3 } from "path";
3593
+ import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash as createHash2 } from "crypto";
3181
3594
  var SESSION_TTL_MS = 30 * 60 * 1000;
3595
+ function getVaultDir() {
3596
+ return getDataDir();
3597
+ }
3598
+ function getVaultConfigPath() {
3599
+ return join3(getDataDir(), "vault.json");
3600
+ }
3601
+ function getVaultSessionPath() {
3602
+ return join3(getStateDir(), ".vault-session");
3603
+ }
3604
+ function getDocumentsDir() {
3605
+ const dir = join3(getDataDir(), "documents");
3606
+ if (!existsSync3(dir))
3607
+ mkdirSync3(dir, { recursive: true });
3608
+ return dir;
3609
+ }
3182
3610
  var _derivedKey = null;
3183
3611
  function deriveKey(passphrase, salt) {
3184
3612
  return pbkdf2Sync(passphrase, salt, 1e5, 32, "sha512");
@@ -3188,16 +3616,17 @@ function saveSession(key) {
3188
3616
  key: key.toString("hex"),
3189
3617
  expires_at: new Date(Date.now() + SESSION_TTL_MS).toISOString()
3190
3618
  };
3191
- writeFileSync(VAULT_SESSION, JSON.stringify(session), { mode: 384 });
3619
+ writeFileSync(getVaultSessionPath(), JSON.stringify(session), { mode: 384 });
3192
3620
  }
3193
3621
  function loadSession() {
3194
- if (!existsSync3(VAULT_SESSION))
3622
+ const sessionPath = getVaultSessionPath();
3623
+ if (!existsSync3(sessionPath))
3195
3624
  return null;
3196
3625
  try {
3197
- const session = JSON.parse(readFileSync(VAULT_SESSION, "utf-8"));
3626
+ const session = JSON.parse(readFileSync(sessionPath, "utf-8"));
3198
3627
  if (new Date(session.expires_at).getTime() < Date.now()) {
3199
3628
  try {
3200
- unlinkSync(VAULT_SESSION);
3629
+ unlinkSync(sessionPath);
3201
3630
  } catch {}
3202
3631
  return null;
3203
3632
  }
@@ -3208,33 +3637,33 @@ function loadSession() {
3208
3637
  }
3209
3638
  function clearSession() {
3210
3639
  try {
3211
- if (existsSync3(VAULT_SESSION))
3212
- unlinkSync(VAULT_SESSION);
3640
+ if (existsSync3(getVaultSessionPath()))
3641
+ unlinkSync(getVaultSessionPath());
3213
3642
  } catch {}
3214
3643
  }
3215
3644
  function initVault(passphrase) {
3216
- if (!existsSync3(VAULT_DIR))
3217
- mkdirSync3(VAULT_DIR, { recursive: true });
3218
- if (!existsSync3(DOCUMENTS_DIR))
3219
- mkdirSync3(DOCUMENTS_DIR, { recursive: true });
3645
+ const vaultDir = getVaultDir();
3646
+ if (!existsSync3(vaultDir))
3647
+ mkdirSync3(vaultDir, { recursive: true });
3648
+ mkdirSync3(getDocumentsDir(), { recursive: true });
3220
3649
  const salt = randomBytes(32);
3221
3650
  const key = deriveKey(passphrase, salt);
3222
- const keyHash = createHash("sha256").update(key).digest("hex");
3651
+ const keyHash = createHash2("sha256").update(key).digest("hex");
3223
3652
  const config = { salt: salt.toString("hex"), key_hash: keyHash, created_at: new Date().toISOString() };
3224
- writeFileSync(VAULT_CONFIG, JSON.stringify(config, null, 2));
3653
+ writeFileSync(getVaultConfigPath(), JSON.stringify(config, null, 2));
3225
3654
  _derivedKey = key;
3226
3655
  saveSession(key);
3227
3656
  }
3228
3657
  function isVaultInitialized() {
3229
- return existsSync3(VAULT_CONFIG);
3658
+ return existsSync3(getVaultConfigPath());
3230
3659
  }
3231
3660
  function unlockVault(passphrase) {
3232
- if (!existsSync3(VAULT_CONFIG))
3661
+ if (!existsSync3(getVaultConfigPath()))
3233
3662
  throw new Error("Vault not initialized. Run 'contacts vault init' first.");
3234
- const config = JSON.parse(readFileSync(VAULT_CONFIG, "utf-8"));
3663
+ const config = JSON.parse(readFileSync(getVaultConfigPath(), "utf-8"));
3235
3664
  const salt = Buffer.from(config.salt, "hex");
3236
3665
  const key = deriveKey(passphrase, salt);
3237
- const keyHash = createHash("sha256").update(key).digest("hex");
3666
+ const keyHash = createHash2("sha256").update(key).digest("hex");
3238
3667
  if (keyHash !== config.key_hash)
3239
3668
  return false;
3240
3669
  _derivedKey = key;
@@ -3286,10 +3715,11 @@ function decrypt(ciphertext, iv) {
3286
3715
  return decrypted;
3287
3716
  }
3288
3717
  function storeFile(sourcePath, entityId) {
3289
- if (!existsSync3(DOCUMENTS_DIR))
3290
- mkdirSync3(DOCUMENTS_DIR, { recursive: true });
3718
+ const documentsDir = getDocumentsDir();
3719
+ if (!existsSync3(documentsDir))
3720
+ mkdirSync3(documentsDir, { recursive: true });
3291
3721
  const ext = sourcePath.split(".").pop() || "bin";
3292
- const destPath = join2(DOCUMENTS_DIR, `${entityId}.${ext}`);
3722
+ const destPath = join3(documentsDir, `${entityId}.${ext}`);
3293
3723
  const data = readFileSync(sourcePath);
3294
3724
  writeFileSync(destPath, data);
3295
3725
  return destPath;
@@ -4183,8 +4613,8 @@ async function assembleContext(contactIds, format = "meeting_prep", db) {
4183
4613
  // src/lib/images.ts
4184
4614
  init_database();
4185
4615
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, copyFileSync as copyFileSync2, unlinkSync as unlinkSync3, readdirSync as readdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
4186
- import { join as join3, extname, basename } from "path";
4187
- var IMAGES_DIR = join3(getDataDir(), "images");
4616
+ import { join as join4, extname, basename } from "path";
4617
+ var IMAGES_DIR = join4(getDataDir(), "images");
4188
4618
  function ensureImagesDir() {
4189
4619
  if (!existsSync5(IMAGES_DIR))
4190
4620
  mkdirSync4(IMAGES_DIR, { recursive: true });
@@ -4197,14 +4627,14 @@ function saveImage(entityId, source, options) {
4197
4627
  const ext2 = base64Match[1] === "jpeg" ? "jpg" : base64Match[1];
4198
4628
  const data = Buffer.from(base64Match[2], "base64");
4199
4629
  const filename2 = `${entityId}.${ext2}`;
4200
- writeFileSync2(join3(IMAGES_DIR, filename2), data);
4630
+ writeFileSync2(join4(IMAGES_DIR, filename2), data);
4201
4631
  return filename2;
4202
4632
  }
4203
4633
  if (!existsSync5(source) && /^[A-Za-z0-9+/=\n\r]+$/.test(source.trim()) && source.length > 100) {
4204
4634
  const ext2 = options?.format || "jpg";
4205
4635
  const data = Buffer.from(source.trim(), "base64");
4206
4636
  const filename2 = `${entityId}.${ext2}`;
4207
- writeFileSync2(join3(IMAGES_DIR, filename2), data);
4637
+ writeFileSync2(join4(IMAGES_DIR, filename2), data);
4208
4638
  return filename2;
4209
4639
  }
4210
4640
  if (!existsSync5(source)) {
@@ -4216,14 +4646,14 @@ function saveImage(entityId, source, options) {
4216
4646
  throw new Error(`Unsupported image format: ${ext}. Supported: ${validExts.join(", ")}`);
4217
4647
  }
4218
4648
  const filename = `${entityId}.${ext === "jpeg" ? "jpg" : ext}`;
4219
- copyFileSync2(source, join3(IMAGES_DIR, filename));
4649
+ copyFileSync2(source, join4(IMAGES_DIR, filename));
4220
4650
  return filename;
4221
4651
  }
4222
4652
  function getImagePath(entityId) {
4223
4653
  ensureImagesDir();
4224
4654
  const files = readdirSync2(IMAGES_DIR);
4225
4655
  const match = files.find((f) => f.startsWith(`${entityId}.`));
4226
- return match ? join3(IMAGES_DIR, match) : null;
4656
+ return match ? join4(IMAGES_DIR, match) : null;
4227
4657
  }
4228
4658
  function getImageAsBase64(entityId) {
4229
4659
  const path = getImagePath(entityId);
@@ -4240,7 +4670,7 @@ function deleteImage(entityId) {
4240
4670
  let deleted = false;
4241
4671
  for (const f of files) {
4242
4672
  if (f.startsWith(`${entityId}.`)) {
4243
- unlinkSync3(join3(IMAGES_DIR, f));
4673
+ unlinkSync3(join4(IMAGES_DIR, f));
4244
4674
  deleted = true;
4245
4675
  }
4246
4676
  }
@@ -4252,7 +4682,7 @@ function listImages() {
4252
4682
  return files.map((f) => ({
4253
4683
  entity_id: basename(f, extname(f)),
4254
4684
  filename: f,
4255
- path: join3(IMAGES_DIR, f)
4685
+ path: join4(IMAGES_DIR, f)
4256
4686
  }));
4257
4687
  }
4258
4688
 
@@ -4749,6 +5179,15 @@ class LocalStore {
4749
5179
  async listContactIdsByProject(projectId) {
4750
5180
  return listContactIdsByProject(projectId, this.db);
4751
5181
  }
5182
+ async readContactProjectMembership(contactId, projectId) {
5183
+ return readContactProjectMembership(contactId, projectId, this.db);
5184
+ }
5185
+ async listContactProjectMemberships(projectId, maxItems) {
5186
+ return listContactProjectMemberships(projectId, maxItems, this.db);
5187
+ }
5188
+ async mutateContactProjectMembership(direction, input) {
5189
+ return mutateContactProjectMembership(direction, input, this.db);
5190
+ }
4752
5191
  async createCompany(input) {
4753
5192
  return createCompany(input, this.db);
4754
5193
  }
@@ -5371,20 +5810,33 @@ class ApiStore {
5371
5810
  async findContactByEmailAddress(address) {
5372
5811
  return this.getContactByEmail(address);
5373
5812
  }
5374
- async linkContactToProject() {
5375
- return unavailable("linkContactToProject");
5813
+ async linkContactToProject(contactId, projectId) {
5814
+ await this.client.transport.put(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
5815
+ }
5816
+ async unlinkContactFromProject(contactId, projectId) {
5817
+ await this.del(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
5818
+ }
5819
+ async getContactProjectIds(contactId) {
5820
+ return pick(await this.g(`/contacts/${this.enc(contactId)}/projects`), "project_ids") ?? [];
5821
+ }
5822
+ async setContactProjects(contactId, projectIds) {
5823
+ await this.client.transport.put(`/contacts/${this.enc(contactId)}/projects`, { project_ids: projectIds });
5376
5824
  }
5377
- async unlinkContactFromProject() {
5378
- return unavailable("unlinkContactFromProject");
5825
+ async listContactIdsByProject(projectId) {
5826
+ return pick(await this.g(`/projects/${this.enc(projectId)}/contacts`), "contact_ids") ?? [];
5379
5827
  }
5380
- async getContactProjectIds() {
5381
- return unavailable("getContactProjectIds");
5828
+ async readContactProjectMembership(contactId, projectId) {
5829
+ return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships/${this.enc(contactId)}`);
5382
5830
  }
5383
- async setContactProjects() {
5384
- return unavailable("setContactProjects");
5831
+ async listContactProjectMemberships(projectId, maxItems) {
5832
+ return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships`, { query: { max_items: maxItems } });
5385
5833
  }
5386
- async listContactIdsByProject() {
5387
- return unavailable("listContactIdsByProject");
5834
+ async mutateContactProjectMembership(direction, input) {
5835
+ return this.client.transport.post(`/projects/${this.enc(input.project_id)}/contact-memberships/${this.enc(input.contact_id)}/${direction}`, {
5836
+ operation_id: input.operation_id,
5837
+ step_id: input.step_id,
5838
+ expected_version: input.expected_version
5839
+ });
5388
5840
  }
5389
5841
  async createCompany(input) {
5390
5842
  const res = await this.client.create("companies", stripUndefined(input));
@@ -5907,6 +6359,7 @@ function resetStoreCache() {
5907
6359
  }
5908
6360
 
5909
6361
  // src/index.ts
6362
+ init_project_memberships();
5910
6363
  init_types();
5911
6364
 
5912
6365
  // src/sdk/v1.generated.ts
@@ -6013,6 +6466,34 @@ class ContactsV1Client {
6013
6466
  init
6014
6467
  });
6015
6468
  }
6469
+ async getContactProjectIds(contactId, init) {
6470
+ return this.request("GET", `/v1/contacts/${encodeURIComponent(String(contactId))}/projects`, {
6471
+ body: undefined,
6472
+ query: undefined,
6473
+ init
6474
+ });
6475
+ }
6476
+ async setContactProjects(contactId, body, init) {
6477
+ return this.request("PUT", `/v1/contacts/${encodeURIComponent(String(contactId))}/projects`, {
6478
+ body,
6479
+ query: undefined,
6480
+ init
6481
+ });
6482
+ }
6483
+ async linkContactToProject(contactId, projectId, init) {
6484
+ return this.request("PUT", `/v1/contacts/${encodeURIComponent(String(contactId))}/projects/${encodeURIComponent(String(projectId))}`, {
6485
+ body: undefined,
6486
+ query: undefined,
6487
+ init
6488
+ });
6489
+ }
6490
+ async unlinkContactFromProject(contactId, projectId, init) {
6491
+ return this.request("DELETE", `/v1/contacts/${encodeURIComponent(String(contactId))}/projects/${encodeURIComponent(String(projectId))}`, {
6492
+ body: undefined,
6493
+ query: undefined,
6494
+ init
6495
+ });
6496
+ }
6016
6497
  async addTagToContact(contactId, tagId, init) {
6017
6498
  return this.request("PUT", `/v1/contacts/${encodeURIComponent(String(contactId))}/tags/${encodeURIComponent(String(tagId))}`, {
6018
6499
  body: undefined,
@@ -6048,6 +6529,41 @@ class ContactsV1Client {
6048
6529
  init
6049
6530
  });
6050
6531
  }
6532
+ async listContactProjectMemberships(projectId, query, init) {
6533
+ return this.request("GET", `/v1/projects/${encodeURIComponent(String(projectId))}/contact-memberships`, {
6534
+ body: undefined,
6535
+ query,
6536
+ init
6537
+ });
6538
+ }
6539
+ async readContactProjectMembership(projectId, contactId, init) {
6540
+ return this.request("GET", `/v1/projects/${encodeURIComponent(String(projectId))}/contact-memberships/${encodeURIComponent(String(contactId))}`, {
6541
+ body: undefined,
6542
+ query: undefined,
6543
+ init
6544
+ });
6545
+ }
6546
+ async attachContactProjectMembership(projectId, contactId, body, init) {
6547
+ return this.request("POST", `/v1/projects/${encodeURIComponent(String(projectId))}/contact-memberships/${encodeURIComponent(String(contactId))}/attach`, {
6548
+ body,
6549
+ query: undefined,
6550
+ init
6551
+ });
6552
+ }
6553
+ async detachContactProjectMembership(projectId, contactId, body, init) {
6554
+ return this.request("POST", `/v1/projects/${encodeURIComponent(String(projectId))}/contact-memberships/${encodeURIComponent(String(contactId))}/detach`, {
6555
+ body,
6556
+ query: undefined,
6557
+ init
6558
+ });
6559
+ }
6560
+ async listContactIdsByProject(projectId, init) {
6561
+ return this.request("GET", `/v1/projects/${encodeURIComponent(String(projectId))}/contacts`, {
6562
+ body: undefined,
6563
+ query: undefined,
6564
+ init
6565
+ });
6566
+ }
6051
6567
  async getStats(init) {
6052
6568
  return this.request("GET", `/v1/stats`, {
6053
6569
  body: undefined,
@@ -6093,7 +6609,7 @@ class ContactsV1Client {
6093
6609
  }
6094
6610
  // src/lib/package-version.ts
6095
6611
  import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
6096
- import { dirname as dirname2, join as join4 } from "path";
6612
+ import { dirname as dirname2, join as join5 } from "path";
6097
6613
  import { fileURLToPath } from "url";
6098
6614
  var cached2 = null;
6099
6615
  function getPackageVersion() {
@@ -6102,7 +6618,7 @@ function getPackageVersion() {
6102
6618
  try {
6103
6619
  let dir = dirname2(fileURLToPath(import.meta.url));
6104
6620
  for (let i = 0;i < 8; i++) {
6105
- const pkgPath = join4(dir, "package.json");
6621
+ const pkgPath = join5(dir, "package.json");
6106
6622
  if (existsSync6(pkgPath)) {
6107
6623
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
6108
6624
  if (pkg.name === "@hasna/contacts" && pkg.version) {
@@ -6258,6 +6774,58 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
6258
6774
  color: { type: "string" },
6259
6775
  description: { type: "string" }
6260
6776
  }
6777
+ },
6778
+ ProjectIdsInput: {
6779
+ type: "object",
6780
+ required: ["project_ids"],
6781
+ properties: {
6782
+ project_ids: {
6783
+ type: "array",
6784
+ items: { type: "string", minLength: 1 },
6785
+ uniqueItems: true
6786
+ }
6787
+ }
6788
+ },
6789
+ ContactProjectMembershipSnapshot: {
6790
+ type: "object",
6791
+ required: ["contact_id", "project_id", "linked", "version"],
6792
+ properties: {
6793
+ contact_id: { type: "string" },
6794
+ project_id: { type: "string" },
6795
+ linked: { type: "boolean" },
6796
+ version: { type: "string" }
6797
+ }
6798
+ },
6799
+ ContactProjectMembershipMutationInput: {
6800
+ type: "object",
6801
+ required: ["operation_id", "step_id", "expected_version"],
6802
+ properties: {
6803
+ operation_id: { type: "string", minLength: 1 },
6804
+ step_id: { type: "string", minLength: 1 },
6805
+ expected_version: { type: "string", minLength: 1 }
6806
+ }
6807
+ },
6808
+ ContactProjectMembershipMutationResult: {
6809
+ type: "object",
6810
+ required: ["outcome", "operation_id", "step_id", "before", "after", "receipt_id"],
6811
+ properties: {
6812
+ outcome: { type: "string", enum: ["accepted", "duplicate_of_accepted"] },
6813
+ operation_id: { type: "string" },
6814
+ step_id: { type: "string" },
6815
+ before: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" },
6816
+ after: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" },
6817
+ receipt_id: { type: "string" }
6818
+ }
6819
+ },
6820
+ ContactProjectMembershipListResult: {
6821
+ type: "object",
6822
+ required: ["project_id", "contact_ids", "complete", "membership_revision"],
6823
+ properties: {
6824
+ project_id: { type: "string" },
6825
+ contact_ids: { type: "array", items: { type: "string" } },
6826
+ complete: { type: "boolean", const: true },
6827
+ membership_revision: { type: "string" }
6828
+ }
6261
6829
  }
6262
6830
  }
6263
6831
  },
@@ -6458,6 +7026,167 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
6458
7026
  })
6459
7027
  }
6460
7028
  },
7029
+ "/v1/contacts/{contact_id}/projects": {
7030
+ get: {
7031
+ operationId: "getContactProjectIds",
7032
+ summary: "List project ids attached to a contact",
7033
+ parameters: [
7034
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
7035
+ ],
7036
+ responses: objResponse({
7037
+ contact_id: { type: "string" },
7038
+ project_ids: { type: "array", items: { type: "string" } }
7039
+ })
7040
+ },
7041
+ put: {
7042
+ operationId: "setContactProjects",
7043
+ summary: "Atomically replace a contact's project memberships",
7044
+ parameters: [
7045
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
7046
+ ],
7047
+ requestBody: {
7048
+ required: true,
7049
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectIdsInput" } } }
7050
+ },
7051
+ responses: objResponse({
7052
+ contact_id: { type: "string" },
7053
+ project_ids: { type: "array", items: { type: "string" } }
7054
+ })
7055
+ }
7056
+ },
7057
+ "/v1/contacts/{contact_id}/projects/{project_id}": {
7058
+ put: {
7059
+ operationId: "linkContactToProject",
7060
+ summary: "Attach a contact to a project idempotently",
7061
+ parameters: [
7062
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } },
7063
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
7064
+ ],
7065
+ responses: objResponse({
7066
+ attached: { type: "boolean" },
7067
+ contact_id: { type: "string" },
7068
+ project_id: { type: "string" }
7069
+ })
7070
+ },
7071
+ delete: {
7072
+ operationId: "unlinkContactFromProject",
7073
+ summary: "Detach a contact from a project",
7074
+ parameters: [
7075
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } },
7076
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
7077
+ ],
7078
+ responses: objResponse({
7079
+ removed: { type: "boolean" },
7080
+ contact_id: { type: "string" },
7081
+ project_id: { type: "string" }
7082
+ })
7083
+ }
7084
+ },
7085
+ "/v1/projects/{project_id}/contacts": {
7086
+ get: {
7087
+ operationId: "listContactIdsByProject",
7088
+ summary: "List contact ids attached to a project",
7089
+ parameters: [
7090
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
7091
+ ],
7092
+ responses: objResponse({
7093
+ project_id: { type: "string" },
7094
+ contact_ids: { type: "array", items: { type: "string" } }
7095
+ })
7096
+ }
7097
+ },
7098
+ "/v1/projects/{project_id}/contact-memberships": {
7099
+ get: {
7100
+ operationId: "listContactProjectMemberships",
7101
+ summary: "List the complete authoritative contact membership collection for a project",
7102
+ parameters: [
7103
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
7104
+ { name: "max_items", in: "query", required: true, schema: { type: "integer", minimum: 1 } }
7105
+ ],
7106
+ responses: {
7107
+ "200": {
7108
+ content: {
7109
+ "application/json": {
7110
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipListResult" }
7111
+ }
7112
+ }
7113
+ }
7114
+ }
7115
+ }
7116
+ },
7117
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}": {
7118
+ get: {
7119
+ operationId: "readContactProjectMembership",
7120
+ summary: "Read one authoritative contact-project membership snapshot",
7121
+ parameters: [
7122
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
7123
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
7124
+ ],
7125
+ responses: {
7126
+ "200": {
7127
+ content: {
7128
+ "application/json": {
7129
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" }
7130
+ }
7131
+ }
7132
+ }
7133
+ }
7134
+ }
7135
+ },
7136
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}/attach": {
7137
+ post: {
7138
+ operationId: "attachContactProjectMembership",
7139
+ summary: "Attach a contact to a project under expected-version CAS with a replay-safe receipt",
7140
+ parameters: [
7141
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
7142
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
7143
+ ],
7144
+ requestBody: {
7145
+ required: true,
7146
+ content: {
7147
+ "application/json": {
7148
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationInput" }
7149
+ }
7150
+ }
7151
+ },
7152
+ responses: {
7153
+ "200": {
7154
+ content: {
7155
+ "application/json": {
7156
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationResult" }
7157
+ }
7158
+ }
7159
+ }
7160
+ }
7161
+ }
7162
+ },
7163
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}/detach": {
7164
+ post: {
7165
+ operationId: "detachContactProjectMembership",
7166
+ summary: "Detach a contact from a project under expected-version CAS with a replay-safe receipt",
7167
+ parameters: [
7168
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
7169
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
7170
+ ],
7171
+ requestBody: {
7172
+ required: true,
7173
+ content: {
7174
+ "application/json": {
7175
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationInput" }
7176
+ }
7177
+ }
7178
+ },
7179
+ responses: {
7180
+ "200": {
7181
+ content: {
7182
+ "application/json": {
7183
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationResult" }
7184
+ }
7185
+ }
7186
+ }
7187
+ }
7188
+ }
7189
+ },
6461
7190
  "/v1/stats": {
6462
7191
  get: {
6463
7192
  operationId: "getStats",
@@ -6482,6 +7211,7 @@ export {
6482
7211
  DuplicateAudienceIdError,
6483
7212
  ContactsV1Client,
6484
7213
  ApiError as ContactsV1ApiError,
7214
+ ContactProjectMembershipConflictError,
6485
7215
  ContactNotFoundError,
6486
7216
  CompanyNotFoundError,
6487
7217
  CONSENT_STATUSES,