@hasna/contacts 0.6.34 → 0.6.35

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 (212) hide show
  1. package/dashboard/dist/assets/index-0l6aQb1t.css +1 -0
  2. package/dashboard/dist/assets/index-opnZdkVD.js +229 -0
  3. package/dashboard/dist/index.html +13 -0
  4. package/dist/cli/commands/advanced.d.ts +3 -0
  5. package/dist/cli/commands/advanced.d.ts.map +1 -0
  6. package/dist/cli/commands/audience.d.ts +18 -0
  7. package/dist/cli/commands/audience.d.ts.map +1 -0
  8. package/dist/cli/commands/core.d.ts +3 -0
  9. package/dist/cli/commands/core.d.ts.map +1 -0
  10. package/dist/cli/commands/crm.d.ts +3 -0
  11. package/dist/cli/commands/crm.d.ts.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -0
  14. package/dist/cli/index.js +1060 -46
  15. package/dist/cli/storage.d.ts +3 -0
  16. package/dist/cli/storage.d.ts.map +1 -0
  17. package/dist/cli/utils.d.ts +10 -0
  18. package/dist/cli/utils.d.ts.map +1 -0
  19. package/dist/cloud/http-storage.d.ts +88 -0
  20. package/dist/cloud/http-storage.d.ts.map +1 -0
  21. package/dist/db/activity.d.ts +15 -0
  22. package/dist/db/activity.d.ts.map +1 -0
  23. package/dist/db/applications.d.ts +10 -0
  24. package/dist/db/applications.d.ts.map +1 -0
  25. package/dist/db/audiences.d.ts +51 -0
  26. package/dist/db/audiences.d.ts.map +1 -0
  27. package/dist/db/companies.d.ts +19 -0
  28. package/dist/db/companies.d.ts.map +1 -0
  29. package/dist/db/contact-tasks.d.ts +19 -0
  30. package/dist/db/contact-tasks.d.ts.map +1 -0
  31. package/dist/db/contacts.d.ts +30 -0
  32. package/dist/db/contacts.d.ts.map +1 -0
  33. package/dist/db/coordination.d.ts +30 -0
  34. package/dist/db/coordination.d.ts.map +1 -0
  35. package/dist/db/database.d.ts +8 -0
  36. package/dist/db/database.d.ts.map +1 -0
  37. package/dist/db/deals.d.ts +14 -0
  38. package/dist/db/deals.d.ts.map +1 -0
  39. package/dist/db/documents.d.ts +38 -0
  40. package/dist/db/documents.d.ts.map +1 -0
  41. package/dist/db/events.d.ts +14 -0
  42. package/dist/db/events.d.ts.map +1 -0
  43. package/dist/db/field-history.d.ts +17 -0
  44. package/dist/db/field-history.d.ts.map +1 -0
  45. package/dist/db/freshness.d.ts +24 -0
  46. package/dist/db/freshness.d.ts.map +1 -0
  47. package/dist/db/graph.d.ts +21 -0
  48. package/dist/db/graph.d.ts.map +1 -0
  49. package/dist/db/groups.d.ts +22 -0
  50. package/dist/db/groups.d.ts.map +1 -0
  51. package/dist/db/health.d.ts +40 -0
  52. package/dist/db/health.d.ts.map +1 -0
  53. package/dist/db/identity.d.ts +32 -0
  54. package/dist/db/identity.d.ts.map +1 -0
  55. package/dist/db/job-history.d.ts +29 -0
  56. package/dist/db/job-history.d.ts.map +1 -0
  57. package/dist/db/learnings.d.ts +43 -0
  58. package/dist/db/learnings.d.ts.map +1 -0
  59. package/dist/db/notes.d.ts +8 -0
  60. package/dist/db/notes.d.ts.map +1 -0
  61. package/dist/db/org-chart.d.ts +37 -0
  62. package/dist/db/org-chart.d.ts.map +1 -0
  63. package/dist/db/org-members.d.ts +9 -0
  64. package/dist/db/org-members.d.ts.map +1 -0
  65. package/dist/db/paths.d.ts +3 -0
  66. package/dist/db/paths.d.ts.map +1 -0
  67. package/dist/db/pg-migrations.d.ts +8 -0
  68. package/dist/db/pg-migrations.d.ts.map +1 -0
  69. package/dist/db/project-memberships.d.ts +8 -0
  70. package/dist/db/project-memberships.d.ts.map +1 -0
  71. package/dist/db/relationships.d.ts +30 -0
  72. package/dist/db/relationships.d.ts.map +1 -0
  73. package/dist/db/signals.d.ts +18 -0
  74. package/dist/db/signals.d.ts.map +1 -0
  75. package/dist/db/sqlite-adapter.d.ts +15 -0
  76. package/dist/db/sqlite-adapter.d.ts.map +1 -0
  77. package/dist/db/storage.d.ts +15 -0
  78. package/dist/db/storage.d.ts.map +1 -0
  79. package/dist/db/tags.d.ts +14 -0
  80. package/dist/db/tags.d.ts.map +1 -0
  81. package/dist/db/tombstones.d.ts +19 -0
  82. package/dist/db/tombstones.d.ts.map +1 -0
  83. package/dist/db/vendor-comms.d.ts +15 -0
  84. package/dist/db/vendor-comms.d.ts.map +1 -0
  85. package/dist/generated/storage-kit/health.d.ts +20 -0
  86. package/dist/generated/storage-kit/health.d.ts.map +1 -0
  87. package/dist/generated/storage-kit/index.d.ts +8 -0
  88. package/dist/generated/storage-kit/index.d.ts.map +1 -0
  89. package/dist/generated/storage-kit/migrations.d.ts +48 -0
  90. package/dist/generated/storage-kit/migrations.d.ts.map +1 -0
  91. package/dist/generated/storage-kit/mode.d.ts +48 -0
  92. package/dist/generated/storage-kit/mode.d.ts.map +1 -0
  93. package/dist/generated/storage-kit/pool.d.ts +34 -0
  94. package/dist/generated/storage-kit/pool.d.ts.map +1 -0
  95. package/dist/generated/storage-kit/query.d.ts +36 -0
  96. package/dist/generated/storage-kit/query.d.ts.map +1 -0
  97. package/dist/generated/storage-kit/tls.d.ts +26 -0
  98. package/dist/generated/storage-kit/tls.d.ts.map +1 -0
  99. package/dist/index.d.ts +9 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +613 -23
  102. package/dist/lib/apple-contacts.d.ts +3 -0
  103. package/dist/lib/apple-contacts.d.ts.map +1 -0
  104. package/dist/lib/audience-contract.d.ts +215 -0
  105. package/dist/lib/audience-contract.d.ts.map +1 -0
  106. package/dist/lib/audit.d.ts +12 -0
  107. package/dist/lib/audit.d.ts.map +1 -0
  108. package/dist/lib/brief.d.ts +3 -0
  109. package/dist/lib/brief.d.ts.map +1 -0
  110. package/dist/lib/config.d.ts +7 -0
  111. package/dist/lib/config.d.ts.map +1 -0
  112. package/dist/lib/connector.d.ts +44 -0
  113. package/dist/lib/connector.d.ts.map +1 -0
  114. package/dist/lib/context.d.ts +5 -0
  115. package/dist/lib/context.d.ts.map +1 -0
  116. package/dist/lib/dedup.d.ts +10 -0
  117. package/dist/lib/dedup.d.ts.map +1 -0
  118. package/dist/lib/document-scanner.d.ts +8 -0
  119. package/dist/lib/document-scanner.d.ts.map +1 -0
  120. package/dist/lib/embeddings.d.ts +9 -0
  121. package/dist/lib/embeddings.d.ts.map +1 -0
  122. package/dist/lib/export.d.ts +8 -0
  123. package/dist/lib/export.d.ts.map +1 -0
  124. package/dist/lib/freshness.d.ts +19 -0
  125. package/dist/lib/freshness.d.ts.map +1 -0
  126. package/dist/lib/gmail-import.d.ts +51 -0
  127. package/dist/lib/gmail-import.d.ts.map +1 -0
  128. package/dist/lib/google-contacts.d.ts +115 -0
  129. package/dist/lib/google-contacts.d.ts.map +1 -0
  130. package/dist/lib/images.d.ts +30 -0
  131. package/dist/lib/images.d.ts.map +1 -0
  132. package/dist/lib/import.d.ts +10 -0
  133. package/dist/lib/import.d.ts.map +1 -0
  134. package/dist/lib/learning-maintenance.d.ts +8 -0
  135. package/dist/lib/learning-maintenance.d.ts.map +1 -0
  136. package/dist/lib/mailery-sync.d.ts +46 -0
  137. package/dist/lib/mailery-sync.d.ts.map +1 -0
  138. package/dist/lib/meeting-capture.d.ts +15 -0
  139. package/dist/lib/meeting-capture.d.ts.map +1 -0
  140. package/dist/lib/package-version.d.ts +6 -0
  141. package/dist/lib/package-version.d.ts.map +1 -0
  142. package/dist/lib/signals.d.ts +9 -0
  143. package/dist/lib/signals.d.ts.map +1 -0
  144. package/dist/lib/signature-parser.d.ts +36 -0
  145. package/dist/lib/signature-parser.d.ts.map +1 -0
  146. package/dist/lib/stats.d.ts +24 -0
  147. package/dist/lib/stats.d.ts.map +1 -0
  148. package/dist/lib/timeline.d.ts +11 -0
  149. package/dist/lib/timeline.d.ts.map +1 -0
  150. package/dist/lib/upcoming.d.ts +14 -0
  151. package/dist/lib/upcoming.d.ts.map +1 -0
  152. package/dist/lib/vault.d.ts +24 -0
  153. package/dist/lib/vault.d.ts.map +1 -0
  154. package/dist/mcp/handlers/advanced.d.ts +14 -0
  155. package/dist/mcp/handlers/advanced.d.ts.map +1 -0
  156. package/dist/mcp/handlers/audience.d.ts +9 -0
  157. package/dist/mcp/handlers/audience.d.ts.map +1 -0
  158. package/dist/mcp/handlers/core.d.ts +10 -0
  159. package/dist/mcp/handlers/core.d.ts.map +1 -0
  160. package/dist/mcp/handlers/crm.d.ts +14 -0
  161. package/dist/mcp/handlers/crm.d.ts.map +1 -0
  162. package/dist/mcp/handlers/index.d.ts +4 -0
  163. package/dist/mcp/handlers/index.d.ts.map +1 -0
  164. package/dist/mcp/handlers/types.d.ts +10 -0
  165. package/dist/mcp/handlers/types.d.ts.map +1 -0
  166. package/dist/mcp/http.d.ts +17 -0
  167. package/dist/mcp/http.d.ts.map +1 -0
  168. package/dist/mcp/index.d.ts +5 -0
  169. package/dist/mcp/index.d.ts.map +1 -0
  170. package/dist/mcp/index.js +339 -27
  171. package/dist/mcp/register-tools.d.ts +3 -0
  172. package/dist/mcp/register-tools.d.ts.map +1 -0
  173. package/dist/mcp/schema.d.ts +3 -0
  174. package/dist/mcp/schema.d.ts.map +1 -0
  175. package/dist/mcp/storage-tools.d.ts +3 -0
  176. package/dist/mcp/storage-tools.d.ts.map +1 -0
  177. package/dist/mcp/tools.d.ts +3799 -0
  178. package/dist/mcp/tools.d.ts.map +1 -0
  179. package/dist/sdk/index.d.ts +16 -0
  180. package/dist/sdk/index.d.ts.map +1 -0
  181. package/dist/sdk/index.js +63 -0
  182. package/dist/sdk/v1.generated.d.ts +270 -0
  183. package/dist/sdk/v1.generated.d.ts.map +1 -0
  184. package/dist/server/cloud-index.d.ts +3 -0
  185. package/dist/server/cloud-index.d.ts.map +1 -0
  186. package/dist/server/cloud-serve.d.ts +3 -0
  187. package/dist/server/cloud-serve.d.ts.map +1 -0
  188. package/dist/server/cloud.d.ts +53 -0
  189. package/dist/server/cloud.d.ts.map +1 -0
  190. package/dist/server/index.d.ts +3 -0
  191. package/dist/server/index.d.ts.map +1 -0
  192. package/dist/server/index.js +1022 -46
  193. package/dist/server/openapi.d.ts +1134 -0
  194. package/dist/server/openapi.d.ts.map +1 -0
  195. package/dist/server/pg-store.d.ts +893 -0
  196. package/dist/server/pg-store.d.ts.map +1 -0
  197. package/dist/server/security.d.ts +24 -0
  198. package/dist/server/security.d.ts.map +1 -0
  199. package/dist/server/serve.d.ts +9 -0
  200. package/dist/server/serve.d.ts.map +1 -0
  201. package/dist/server/v1.d.ts +28 -0
  202. package/dist/server/v1.d.ts.map +1 -0
  203. package/dist/store/index.d.ts +387 -0
  204. package/dist/store/index.d.ts.map +1 -0
  205. package/dist/types/index.d.ts +856 -0
  206. package/dist/types/index.d.ts.map +1 -0
  207. package/dist/types/project-memberships.d.ts +32 -0
  208. package/dist/types/project-memberships.d.ts.map +1 -0
  209. package/migrations/0015_contact_project_membership_authority.sql +88 -0
  210. package/package.json +1 -1
  211. package/vendor/fast-uri/README.md +12 -0
  212. package/dist/server/cloud-index.js +0 -9187
package/dist/cli/index.js CHANGED
@@ -2210,7 +2210,10 @@ function uuid() {
2210
2210
  return crypto.randomUUID();
2211
2211
  }
2212
2212
  function now2() {
2213
- return new Date().toISOString();
2213
+ const currentMs = Date.now();
2214
+ const nextMs = currentMs > lastNowMs ? currentMs : lastNowMs + 1;
2215
+ lastNowMs = nextMs;
2216
+ return new Date(nextMs).toISOString();
2214
2217
  }
2215
2218
  function quoteIdentifier(identifier) {
2216
2219
  return `"${identifier.replaceAll('"', '""')}"`;
@@ -2248,7 +2251,7 @@ function runMigrations(db) {
2248
2251
  db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${i})`);
2249
2252
  }
2250
2253
  }
2251
- var MIGRATIONS, _db = null;
2254
+ var MIGRATIONS, _db = null, lastNowMs = 0;
2252
2255
  var init_database = __esm(() => {
2253
2256
  init_sqlite_adapter();
2254
2257
  init_paths();
@@ -2820,6 +2823,81 @@ var init_database = __esm(() => {
2820
2823
  );
2821
2824
 
2822
2825
  CREATE INDEX IF NOT EXISTS idx_contacts_tombstones_deleted_at ON _contacts_tombstones(deleted_at);
2826
+ `,
2827
+ `
2828
+ CREATE TABLE IF NOT EXISTS contact_project_membership_states (
2829
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2830
+ project_id TEXT NOT NULL,
2831
+ linked INTEGER NOT NULL CHECK(linked IN (0, 1)),
2832
+ revision INTEGER NOT NULL DEFAULT 0 CHECK(revision >= 0),
2833
+ updated_at TEXT NOT NULL,
2834
+ PRIMARY KEY (contact_id, project_id)
2835
+ );
2836
+
2837
+ INSERT OR IGNORE INTO contact_project_membership_states
2838
+ (contact_id, project_id, linked, revision, updated_at)
2839
+ SELECT contact_id, project_id, 1, 0, datetime('now')
2840
+ FROM contact_projects;
2841
+
2842
+ CREATE TABLE IF NOT EXISTS contact_project_membership_receipts (
2843
+ receipt_id TEXT PRIMARY KEY,
2844
+ direction TEXT NOT NULL CHECK(direction IN ('attach', 'detach')),
2845
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2846
+ project_id TEXT NOT NULL,
2847
+ operation_id TEXT NOT NULL,
2848
+ step_id TEXT NOT NULL,
2849
+ expected_version TEXT NOT NULL,
2850
+ before_json TEXT NOT NULL,
2851
+ after_json TEXT NOT NULL,
2852
+ created_at TEXT NOT NULL,
2853
+ UNIQUE(operation_id, step_id)
2854
+ );
2855
+
2856
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_states_project
2857
+ ON contact_project_membership_states(project_id, linked, contact_id);
2858
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_receipts_target
2859
+ ON contact_project_membership_receipts(contact_id, project_id, created_at);
2860
+
2861
+ CREATE TRIGGER IF NOT EXISTS sync_contact_project_membership_state_after_insert
2862
+ AFTER INSERT ON contact_projects
2863
+ BEGIN
2864
+ INSERT INTO contact_project_membership_states
2865
+ (contact_id, project_id, linked, revision, updated_at)
2866
+ VALUES (NEW.contact_id, NEW.project_id, 1, 1, datetime('now'))
2867
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
2868
+ linked = 1,
2869
+ revision = CASE
2870
+ WHEN contact_project_membership_states.linked = 1
2871
+ THEN contact_project_membership_states.revision
2872
+ ELSE contact_project_membership_states.revision + 1
2873
+ END,
2874
+ updated_at = CASE
2875
+ WHEN contact_project_membership_states.linked = 1
2876
+ THEN contact_project_membership_states.updated_at
2877
+ ELSE datetime('now')
2878
+ END;
2879
+ END;
2880
+
2881
+ CREATE TRIGGER IF NOT EXISTS sync_contact_project_membership_state_after_delete
2882
+ AFTER DELETE ON contact_projects
2883
+ WHEN EXISTS (SELECT 1 FROM contacts WHERE id = OLD.contact_id)
2884
+ BEGIN
2885
+ INSERT INTO contact_project_membership_states
2886
+ (contact_id, project_id, linked, revision, updated_at)
2887
+ VALUES (OLD.contact_id, OLD.project_id, 0, 1, datetime('now'))
2888
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
2889
+ linked = 0,
2890
+ revision = CASE
2891
+ WHEN contact_project_membership_states.linked = 0
2892
+ THEN contact_project_membership_states.revision
2893
+ ELSE contact_project_membership_states.revision + 1
2894
+ END,
2895
+ updated_at = CASE
2896
+ WHEN contact_project_membership_states.linked = 0
2897
+ THEN contact_project_membership_states.updated_at
2898
+ ELSE datetime('now')
2899
+ END;
2900
+ END;
2823
2901
  `
2824
2902
  ];
2825
2903
  });
@@ -2990,6 +3068,219 @@ var init_tombstones = __esm(() => {
2990
3068
  init_database();
2991
3069
  });
2992
3070
 
3071
+ // src/types/project-memberships.ts
3072
+ var ContactProjectMembershipConflictError;
3073
+ var init_project_memberships = __esm(() => {
3074
+ ContactProjectMembershipConflictError = class ContactProjectMembershipConflictError extends Error {
3075
+ constructor(message) {
3076
+ super(message);
3077
+ this.name = "ContactProjectMembershipConflictError";
3078
+ }
3079
+ };
3080
+ });
3081
+
3082
+ // src/db/project-memberships.ts
3083
+ import { createHash } from "crypto";
3084
+ function digest(value) {
3085
+ return createHash("sha256").update(value).digest("hex");
3086
+ }
3087
+ function membershipVersion(contactId, projectId, linked, revision) {
3088
+ return `cpmv_${digest(JSON.stringify([contactId, projectId, linked, revision])).slice(0, 32)}`;
3089
+ }
3090
+ function receiptId(input) {
3091
+ return `cpmr_${digest(JSON.stringify([
3092
+ input.operation_id,
3093
+ input.step_id,
3094
+ input.contact_id,
3095
+ input.project_id
3096
+ ])).slice(0, 32)}`;
3097
+ }
3098
+ function stateRow(contactId, projectId, db) {
3099
+ const persisted = db.query(`SELECT contact_id, project_id, linked, revision
3100
+ FROM contact_project_membership_states
3101
+ WHERE contact_id = ? AND project_id = ?`).get(contactId, projectId);
3102
+ if (persisted)
3103
+ return persisted;
3104
+ const linked = Boolean(db.query(`SELECT 1 AS present FROM contact_projects WHERE contact_id = ? AND project_id = ?`).get(contactId, projectId));
3105
+ return { contact_id: contactId, project_id: projectId, linked: linked ? 1 : 0, revision: 0 };
3106
+ }
3107
+ function snapshot(row) {
3108
+ const linked = Boolean(row.linked);
3109
+ return {
3110
+ contact_id: row.contact_id,
3111
+ project_id: row.project_id,
3112
+ linked,
3113
+ version: membershipVersion(row.contact_id, row.project_id, linked, row.revision)
3114
+ };
3115
+ }
3116
+ function required(value, name) {
3117
+ const normalized = value.trim();
3118
+ if (!normalized)
3119
+ throw new Error(`${name} is required`);
3120
+ return normalized;
3121
+ }
3122
+ function normalizeInput(input) {
3123
+ return {
3124
+ contact_id: required(input.contact_id, "contact_id"),
3125
+ project_id: required(input.project_id, "project_id"),
3126
+ operation_id: required(input.operation_id, "operation_id"),
3127
+ step_id: required(input.step_id, "step_id"),
3128
+ expected_version: required(input.expected_version, "expected_version")
3129
+ };
3130
+ }
3131
+ function parseSnapshot(value) {
3132
+ return JSON.parse(value);
3133
+ }
3134
+ function replay(row, direction, input) {
3135
+ if (row.direction !== direction || row.contact_id !== input.contact_id || row.project_id !== input.project_id || row.expected_version !== input.expected_version) {
3136
+ throw new ContactProjectMembershipConflictError(`operation_id/step_id already accepted for a different contact-project membership mutation`);
3137
+ }
3138
+ return {
3139
+ outcome: "duplicate_of_accepted",
3140
+ operation_id: row.operation_id,
3141
+ step_id: row.step_id,
3142
+ before: parseSnapshot(row.before_json),
3143
+ after: parseSnapshot(row.after_json),
3144
+ receipt_id: row.receipt_id
3145
+ };
3146
+ }
3147
+ function transitionWithoutReceipt(contactId, projectId, linked, db) {
3148
+ const beforeRow = stateRow(contactId, projectId, db);
3149
+ const changed = Boolean(beforeRow.linked) !== linked;
3150
+ const afterRow = {
3151
+ ...beforeRow,
3152
+ linked: linked ? 1 : 0,
3153
+ revision: changed ? beforeRow.revision + 1 : beforeRow.revision
3154
+ };
3155
+ const changedAt = now2();
3156
+ db.run(`INSERT INTO contact_project_membership_states
3157
+ (contact_id, project_id, linked, revision, updated_at)
3158
+ VALUES (?, ?, ?, ?, ?)
3159
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
3160
+ linked = excluded.linked,
3161
+ revision = excluded.revision,
3162
+ updated_at = excluded.updated_at`, [contactId, projectId, afterRow.linked, afterRow.revision, changedAt]);
3163
+ if (linked) {
3164
+ db.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, projectId]);
3165
+ } else {
3166
+ db.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [contactId, projectId]);
3167
+ }
3168
+ return changed;
3169
+ }
3170
+ function setContactProjectMembershipWithoutReceipt(contactId, projectId, linked, db = getDatabase()) {
3171
+ const normalizedContactId = required(contactId, "contact_id");
3172
+ const normalizedProjectId = required(projectId, "project_id");
3173
+ return db.transaction(() => transitionWithoutReceipt(normalizedContactId, normalizedProjectId, linked, db));
3174
+ }
3175
+ function replaceContactProjectMembershipsWithoutReceipts(contactId, projectIds, db = getDatabase()) {
3176
+ const normalizedContactId = required(contactId, "contact_id");
3177
+ const normalizedProjectIds = [...new Set(projectIds.map((projectId) => required(projectId, "project_id")))];
3178
+ return db.transaction(() => {
3179
+ const currentRows = db.query(`SELECT project_id FROM contact_projects WHERE contact_id = ?`).all(normalizedContactId);
3180
+ const desired = new Set(normalizedProjectIds);
3181
+ const population = new Set([...currentRows.map((row) => row.project_id), ...normalizedProjectIds]);
3182
+ for (const projectId of population) {
3183
+ transitionWithoutReceipt(normalizedContactId, projectId, desired.has(projectId), db);
3184
+ }
3185
+ return normalizedProjectIds;
3186
+ });
3187
+ }
3188
+ function readContactProjectMembership(contactId, projectId, db = getDatabase()) {
3189
+ return snapshot(stateRow(required(contactId, "contact_id"), required(projectId, "project_id"), db));
3190
+ }
3191
+ function listContactProjectMemberships(projectId, maxItems, db = getDatabase()) {
3192
+ const normalizedProjectId = required(projectId, "project_id");
3193
+ if (!Number.isInteger(maxItems) || maxItems < 1)
3194
+ throw new Error("max_items must be a positive integer");
3195
+ const rows = db.query(`SELECT cp.contact_id, cp.project_id,
3196
+ COALESCE(state.linked, 1) AS linked,
3197
+ COALESCE(state.revision, 0) AS revision
3198
+ FROM contact_projects cp
3199
+ LEFT JOIN contact_project_membership_states state
3200
+ ON state.contact_id = cp.contact_id AND state.project_id = cp.project_id
3201
+ WHERE cp.project_id = ?
3202
+ ORDER BY cp.contact_id ASC
3203
+ LIMIT ?`).all(normalizedProjectId, maxItems + 1);
3204
+ if (rows.length > maxItems) {
3205
+ throw new Error(`contact project membership collection exceeds max_items=${maxItems}`);
3206
+ }
3207
+ const contactIds = rows.map((row) => row.contact_id);
3208
+ return {
3209
+ project_id: normalizedProjectId,
3210
+ contact_ids: contactIds,
3211
+ complete: true,
3212
+ membership_revision: `cpml_${digest(JSON.stringify(rows.map((row) => snapshot(row)))).slice(0, 32)}`
3213
+ };
3214
+ }
3215
+ function mutateContactProjectMembership(direction, rawInput, db = getDatabase()) {
3216
+ const input = normalizeInput(rawInput);
3217
+ return db.transaction(() => {
3218
+ const existingReceipt = db.query(`SELECT direction, contact_id, project_id, operation_id, step_id, expected_version,
3219
+ before_json, after_json, receipt_id
3220
+ FROM contact_project_membership_receipts
3221
+ WHERE operation_id = ? AND step_id = ?`).get(input.operation_id, input.step_id);
3222
+ if (existingReceipt)
3223
+ return replay(existingReceipt, direction, input);
3224
+ const contact = db.query(`SELECT id FROM contacts WHERE id = ?`).get(input.contact_id);
3225
+ if (!contact)
3226
+ throw new Error(`contact not found: ${input.contact_id}`);
3227
+ const beforeRow = stateRow(input.contact_id, input.project_id, db);
3228
+ const before = snapshot(beforeRow);
3229
+ if (before.version !== input.expected_version) {
3230
+ throw new ContactProjectMembershipConflictError(`contact project membership expected_version conflict: expected ${input.expected_version}, current ${before.version}`);
3231
+ }
3232
+ const desiredLinked = direction === "attach";
3233
+ const changed = before.linked !== desiredLinked;
3234
+ const afterRow = {
3235
+ ...beforeRow,
3236
+ linked: desiredLinked ? 1 : 0,
3237
+ revision: changed ? beforeRow.revision + 1 : beforeRow.revision
3238
+ };
3239
+ const changedAt = now2();
3240
+ db.run(`INSERT INTO contact_project_membership_states
3241
+ (contact_id, project_id, linked, revision, updated_at)
3242
+ VALUES (?, ?, ?, ?, ?)
3243
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
3244
+ linked = excluded.linked,
3245
+ revision = excluded.revision,
3246
+ updated_at = excluded.updated_at`, [input.contact_id, input.project_id, afterRow.linked, afterRow.revision, changedAt]);
3247
+ if (desiredLinked) {
3248
+ db.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [input.contact_id, input.project_id]);
3249
+ } else {
3250
+ db.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [input.contact_id, input.project_id]);
3251
+ }
3252
+ const after = snapshot(afterRow);
3253
+ const id = receiptId(input);
3254
+ db.run(`INSERT INTO contact_project_membership_receipts (
3255
+ receipt_id, direction, contact_id, project_id, operation_id, step_id,
3256
+ expected_version, before_json, after_json, created_at
3257
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
3258
+ id,
3259
+ direction,
3260
+ input.contact_id,
3261
+ input.project_id,
3262
+ input.operation_id,
3263
+ input.step_id,
3264
+ input.expected_version,
3265
+ JSON.stringify(before),
3266
+ JSON.stringify(after),
3267
+ changedAt
3268
+ ]);
3269
+ return {
3270
+ outcome: changed ? "accepted" : "duplicate_of_accepted",
3271
+ operation_id: input.operation_id,
3272
+ step_id: input.step_id,
3273
+ before,
3274
+ after,
3275
+ receipt_id: id
3276
+ };
3277
+ });
3278
+ }
3279
+ var init_project_memberships2 = __esm(() => {
3280
+ init_database();
3281
+ init_project_memberships();
3282
+ });
3283
+
2993
3284
  // src/db/contacts.ts
2994
3285
  var exports_contacts = {};
2995
3286
  __export(exports_contacts, {
@@ -3588,34 +3879,32 @@ function autoLinkContactToCompany(contactId, db) {
3588
3879
  }
3589
3880
  function linkContactToProject(contactId, projectId, db) {
3590
3881
  const d = db || getDatabase();
3591
- d.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, projectId]);
3882
+ setContactProjectMembershipWithoutReceipt(contactId, projectId, true, d);
3592
3883
  }
3593
3884
  function unlinkContactFromProject(contactId, projectId, db) {
3594
3885
  const d = db || getDatabase();
3595
- d.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [contactId, projectId]);
3886
+ setContactProjectMembershipWithoutReceipt(contactId, projectId, false, d);
3596
3887
  }
3597
3888
  function getContactProjectIds(contactId, db) {
3598
3889
  const d = db || getDatabase();
3599
- const rows = d.query(`SELECT project_id FROM contact_projects WHERE contact_id = ?`).all(contactId);
3890
+ const rows = d.query(`SELECT project_id FROM contact_projects WHERE contact_id = ? ORDER BY project_id ASC`).all(contactId);
3600
3891
  return rows.map((r) => r.project_id);
3601
3892
  }
3602
3893
  function listContactIdsByProject(projectId, db) {
3603
3894
  const d = db || getDatabase();
3604
- const rows = d.query(`SELECT contact_id FROM contact_projects WHERE project_id = ?`).all(projectId);
3895
+ const rows = d.query(`SELECT contact_id FROM contact_projects WHERE project_id = ? ORDER BY contact_id ASC`).all(projectId);
3605
3896
  return rows.map((r) => r.contact_id);
3606
3897
  }
3607
3898
  function setContactProjects(contactId, projectIds, db) {
3608
3899
  const d = db || getDatabase();
3609
- d.run(`DELETE FROM contact_projects WHERE contact_id = ?`, [contactId]);
3610
- for (const pid of projectIds) {
3611
- d.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, pid]);
3612
- }
3900
+ replaceContactProjectMembershipsWithoutReceipts(contactId, projectIds, d);
3613
3901
  }
3614
3902
  var init_contacts = __esm(() => {
3615
3903
  init_types();
3616
3904
  init_database();
3617
3905
  init_activity();
3618
3906
  init_tombstones();
3907
+ init_project_memberships2();
3619
3908
  });
3620
3909
 
3621
3910
  // src/db/companies.ts
@@ -5265,7 +5554,7 @@ var init_org_chart = __esm(() => {
5265
5554
  // src/lib/vault.ts
5266
5555
  import { existsSync as existsSync4, readFileSync, writeFileSync, mkdirSync as mkdirSync3, unlinkSync } from "fs";
5267
5556
  import { join as join3 } from "path";
5268
- import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto";
5557
+ import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash as createHash2 } from "crypto";
5269
5558
  function deriveKey(passphrase, salt) {
5270
5559
  return pbkdf2Sync(passphrase, salt, 1e5, 32, "sha512");
5271
5560
  }
@@ -5305,7 +5594,7 @@ function initVault(passphrase) {
5305
5594
  mkdirSync3(DOCUMENTS_DIR, { recursive: true });
5306
5595
  const salt = randomBytes(32);
5307
5596
  const key = deriveKey(passphrase, salt);
5308
- const keyHash = createHash("sha256").update(key).digest("hex");
5597
+ const keyHash = createHash2("sha256").update(key).digest("hex");
5309
5598
  const config = { salt: salt.toString("hex"), key_hash: keyHash, created_at: new Date().toISOString() };
5310
5599
  writeFileSync(VAULT_CONFIG, JSON.stringify(config, null, 2));
5311
5600
  _derivedKey = key;
@@ -5320,7 +5609,7 @@ function unlockVault(passphrase) {
5320
5609
  const config = JSON.parse(readFileSync(VAULT_CONFIG, "utf-8"));
5321
5610
  const salt = Buffer.from(config.salt, "hex");
5322
5611
  const key = deriveKey(passphrase, salt);
5323
- const keyHash = createHash("sha256").update(key).digest("hex");
5612
+ const keyHash = createHash2("sha256").update(key).digest("hex");
5324
5613
  if (keyHash !== config.key_hash)
5325
5614
  return false;
5326
5615
  _derivedKey = key;
@@ -6915,6 +7204,15 @@ class LocalStore {
6915
7204
  async listContactIdsByProject(projectId) {
6916
7205
  return listContactIdsByProject(projectId, this.db);
6917
7206
  }
7207
+ async readContactProjectMembership(contactId, projectId) {
7208
+ return readContactProjectMembership(contactId, projectId, this.db);
7209
+ }
7210
+ async listContactProjectMemberships(projectId, maxItems) {
7211
+ return listContactProjectMemberships(projectId, maxItems, this.db);
7212
+ }
7213
+ async mutateContactProjectMembership(direction, input) {
7214
+ return mutateContactProjectMembership(direction, input, this.db);
7215
+ }
6918
7216
  async createCompany(input) {
6919
7217
  return createCompany(input, this.db);
6920
7218
  }
@@ -7537,20 +7835,33 @@ class ApiStore {
7537
7835
  async findContactByEmailAddress(address) {
7538
7836
  return this.getContactByEmail(address);
7539
7837
  }
7540
- async linkContactToProject() {
7541
- return unavailable("linkContactToProject");
7838
+ async linkContactToProject(contactId, projectId) {
7839
+ await this.client.transport.put(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
7542
7840
  }
7543
- async unlinkContactFromProject() {
7544
- return unavailable("unlinkContactFromProject");
7841
+ async unlinkContactFromProject(contactId, projectId) {
7842
+ await this.del(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
7843
+ }
7844
+ async getContactProjectIds(contactId) {
7845
+ return pick(await this.g(`/contacts/${this.enc(contactId)}/projects`), "project_ids") ?? [];
7846
+ }
7847
+ async setContactProjects(contactId, projectIds) {
7848
+ await this.client.transport.put(`/contacts/${this.enc(contactId)}/projects`, { project_ids: projectIds });
7545
7849
  }
7546
- async getContactProjectIds() {
7547
- return unavailable("getContactProjectIds");
7850
+ async listContactIdsByProject(projectId) {
7851
+ return pick(await this.g(`/projects/${this.enc(projectId)}/contacts`), "contact_ids") ?? [];
7852
+ }
7853
+ async readContactProjectMembership(contactId, projectId) {
7854
+ return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships/${this.enc(contactId)}`);
7548
7855
  }
7549
- async setContactProjects() {
7550
- return unavailable("setContactProjects");
7856
+ async listContactProjectMemberships(projectId, maxItems) {
7857
+ return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships`, { query: { max_items: maxItems } });
7551
7858
  }
7552
- async listContactIdsByProject() {
7553
- return unavailable("listContactIdsByProject");
7859
+ async mutateContactProjectMembership(direction, input) {
7860
+ return this.client.transport.post(`/projects/${this.enc(input.project_id)}/contact-memberships/${this.enc(input.contact_id)}/${direction}`, {
7861
+ operation_id: input.operation_id,
7862
+ step_id: input.step_id,
7863
+ expected_version: input.expected_version
7864
+ });
7554
7865
  }
7555
7866
  async createCompany(input) {
7556
7867
  const res = await this.client.create("companies", stripUndefined(input));
@@ -8072,6 +8383,7 @@ var init_store = __esm(() => {
8072
8383
  init_database();
8073
8384
  init_storage();
8074
8385
  init_contacts();
8386
+ init_project_memberships2();
8075
8387
  init_companies();
8076
8388
  init_tags();
8077
8389
  init_groups();
@@ -8685,7 +8997,7 @@ var exports_util = {};
8685
8997
  __export(exports_util, {
8686
8998
  unwrapMessage: () => unwrapMessage,
8687
8999
  stringifyPrimitive: () => stringifyPrimitive,
8688
- required: () => required,
9000
+ required: () => required2,
8689
9001
  randomString: () => randomString,
8690
9002
  propertyKeyTypes: () => propertyKeyTypes,
8691
9003
  promiseAllObject: () => promiseAllObject,
@@ -9026,7 +9338,7 @@ function partial(Class, schema, mask) {
9026
9338
  checks: []
9027
9339
  });
9028
9340
  }
9029
- function required(Class, schema, mask) {
9341
+ function required2(Class, schema, mask) {
9030
9342
  const oldShape = schema._zod.def.shape;
9031
9343
  const shape = { ...oldShape };
9032
9344
  if (mask) {
@@ -19753,7 +20065,7 @@ function parseObjectDef(def, refs) {
19753
20065
  type: "object",
19754
20066
  properties: {}
19755
20067
  };
19756
- const required2 = [];
20068
+ const required3 = [];
19757
20069
  const shape = def.shape();
19758
20070
  for (const propName in shape) {
19759
20071
  let propDef = shape[propName];
@@ -19780,11 +20092,11 @@ function parseObjectDef(def, refs) {
19780
20092
  }
19781
20093
  result.properties[propName] = parsedDef;
19782
20094
  if (!propOptional) {
19783
- required2.push(propName);
20095
+ required3.push(propName);
19784
20096
  }
19785
20097
  }
19786
- if (required2.length) {
19787
- result.required = required2;
20098
+ if (required3.length) {
20099
+ result.required = required3;
19788
20100
  }
19789
20101
  const additionalProperties = decideAdditionalProperties(def, refs);
19790
20102
  if (additionalProperties !== undefined) {
@@ -27141,8 +27453,8 @@ var require_discriminator = __commonJS((exports) => {
27141
27453
  if (!tagRequired)
27142
27454
  throw new Error(`discriminator: "${tagName}" must be required`);
27143
27455
  return oneOfMapping;
27144
- function hasRequired({ required: required2 }) {
27145
- return Array.isArray(required2) && required2.includes(tagName);
27456
+ function hasRequired({ required: required3 }) {
27457
+ return Array.isArray(required3) && required3.includes(tagName);
27146
27458
  }
27147
27459
  function addMappings(sch, i) {
27148
27460
  if (sch.const) {
@@ -30588,8 +30900,8 @@ var init_advanced = __esm(() => {
30588
30900
  advancedHandlers = {
30589
30901
  get_field_history: async (a) => json3({ history: await getStore().getFieldHistory(a.contact_id, a.field_name) }),
30590
30902
  get_contact_at: async (a) => {
30591
- const snapshot = await getStore().getContactAt(a.contact_id, a.timestamp);
30592
- return json3({ contact_id: a.contact_id, timestamp: a.timestamp, snapshot });
30903
+ const snapshot2 = await getStore().getContactAt(a.contact_id, a.timestamp);
30904
+ return json3({ contact_id: a.contact_id, timestamp: a.timestamp, snapshot: snapshot2 });
30593
30905
  },
30594
30906
  get_job_history: async (a) => json3({ history: await getStore().getJobHistory(a.contact_id) }),
30595
30907
  add_job_entry: async (a) => json3(await getStore().addJobEntry(a.contact_id, {
@@ -31080,11 +31392,11 @@ function jsonSchemaToZodType(schema) {
31080
31392
  }
31081
31393
  function jsonSchemaToZodObject(schema) {
31082
31394
  const objectSchema = schema && typeof schema === "object" ? schema : {};
31083
- const required2 = new Set(objectSchema.required ?? []);
31395
+ const required3 = new Set(objectSchema.required ?? []);
31084
31396
  const shape = {};
31085
31397
  for (const [key, value] of Object.entries(objectSchema.properties ?? {})) {
31086
31398
  const propertySchema = jsonSchemaToZodType(value);
31087
- shape[key] = required2.has(key) ? propertySchema : propertySchema.optional();
31399
+ shape[key] = required3.has(key) ? propertySchema : propertySchema.optional();
31088
31400
  }
31089
31401
  return describeSchema(exports_external.object(shape).passthrough(), objectSchema.description);
31090
31402
  }
@@ -32782,11 +33094,11 @@ function isLoopbackBindHost(hostname2) {
32782
33094
  const normalized = hostname2.trim().toLowerCase();
32783
33095
  return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(normalized);
32784
33096
  }
32785
- function hasScope(principal, required2) {
32786
- const prefix = required2.split(":")[0];
32787
- return principal.scopes.has("*") || principal.scopes.has(required2) || principal.scopes.has(`${prefix}:*`);
33097
+ function hasScope(principal, required3) {
33098
+ const prefix = required3.split(":")[0];
33099
+ return principal.scopes.has("*") || principal.scopes.has(required3) || principal.scopes.has(`${prefix}:*`);
32788
33100
  }
32789
- function authenticateContactsRequest(req, required2, context = { allowUnauthenticatedLoopback: false }) {
33101
+ function authenticateContactsRequest(req, required3, context = { allowUnauthenticatedLoopback: false }) {
32790
33102
  const configured = parseTokenRecords();
32791
33103
  const token = bearerToken(req) ?? req.headers.get("x-contacts-token");
32792
33104
  if (token) {
@@ -32794,8 +33106,8 @@ function authenticateContactsRequest(req, required2, context = { allowUnauthenti
32794
33106
  if (!matched)
32795
33107
  return { ok: false, status: 401, message: "Invalid contacts token" };
32796
33108
  const principal = { id: "api-token", scopes: matched.scopes, localDevelopment: false };
32797
- if (!hasScope(principal, required2))
32798
- return { ok: false, status: 403, message: `Missing scope: ${required2}` };
33109
+ if (!hasScope(principal, required3))
33110
+ return { ok: false, status: 403, message: `Missing scope: ${required3}` };
32799
33111
  return { ok: true, principal };
32800
33112
  }
32801
33113
  if (configured.length === 0 && context.allowUnauthenticatedLoopback) {
@@ -33716,6 +34028,94 @@ var init_pg_migrations = __esm(() => {
33716
34028
  CREATE INDEX IF NOT EXISTS idx_contact_tags_tag_contact ON contact_tags(tag_id, contact_id);
33717
34029
 
33718
34030
  INSERT INTO _migrations (version) VALUES (13) ON CONFLICT DO NOTHING;
34031
+ `,
34032
+ `
34033
+ CREATE TABLE IF NOT EXISTS contact_project_membership_states (
34034
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
34035
+ project_id TEXT NOT NULL,
34036
+ linked BOOLEAN NOT NULL,
34037
+ revision BIGINT NOT NULL DEFAULT 0 CHECK(revision >= 0),
34038
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
34039
+ PRIMARY KEY (contact_id, project_id)
34040
+ );
34041
+
34042
+ INSERT INTO contact_project_membership_states
34043
+ (contact_id, project_id, linked, revision, updated_at)
34044
+ SELECT contact_id, project_id, TRUE, 0, NOW()
34045
+ FROM contact_projects
34046
+ ON CONFLICT DO NOTHING;
34047
+
34048
+ CREATE TABLE IF NOT EXISTS contact_project_membership_receipts (
34049
+ receipt_id TEXT PRIMARY KEY,
34050
+ direction TEXT NOT NULL CHECK(direction IN ('attach', 'detach')),
34051
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
34052
+ project_id TEXT NOT NULL,
34053
+ operation_id TEXT NOT NULL,
34054
+ step_id TEXT NOT NULL,
34055
+ expected_version TEXT NOT NULL,
34056
+ before_json JSONB NOT NULL,
34057
+ after_json JSONB NOT NULL,
34058
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
34059
+ UNIQUE(operation_id, step_id)
34060
+ );
34061
+
34062
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_states_project
34063
+ ON contact_project_membership_states(project_id, linked, contact_id);
34064
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_receipts_target
34065
+ ON contact_project_membership_receipts(contact_id, project_id, created_at);
34066
+
34067
+ CREATE OR REPLACE FUNCTION sync_contact_project_membership_state_from_legacy()
34068
+ RETURNS TRIGGER
34069
+ LANGUAGE plpgsql
34070
+ AS $$
34071
+ DECLARE
34072
+ target_contact_id TEXT;
34073
+ target_project_id TEXT;
34074
+ target_linked BOOLEAN;
34075
+ BEGIN
34076
+ IF TG_OP = 'INSERT' THEN
34077
+ target_contact_id := NEW.contact_id;
34078
+ target_project_id := NEW.project_id;
34079
+ target_linked := TRUE;
34080
+ ELSE
34081
+ target_contact_id := OLD.contact_id;
34082
+ target_project_id := OLD.project_id;
34083
+ target_linked := FALSE;
34084
+ IF NOT EXISTS (SELECT 1 FROM contacts WHERE id = target_contact_id) THEN
34085
+ RETURN OLD;
34086
+ END IF;
34087
+ END IF;
34088
+
34089
+ INSERT INTO contact_project_membership_states
34090
+ (contact_id, project_id, linked, revision, updated_at)
34091
+ VALUES (target_contact_id, target_project_id, target_linked, 1, NOW())
34092
+ ON CONFLICT (contact_id, project_id) DO UPDATE SET
34093
+ linked = EXCLUDED.linked,
34094
+ revision = CASE
34095
+ WHEN contact_project_membership_states.linked IS DISTINCT FROM EXCLUDED.linked
34096
+ THEN contact_project_membership_states.revision + 1
34097
+ ELSE contact_project_membership_states.revision
34098
+ END,
34099
+ updated_at = CASE
34100
+ WHEN contact_project_membership_states.linked IS DISTINCT FROM EXCLUDED.linked
34101
+ THEN NOW()
34102
+ ELSE contact_project_membership_states.updated_at
34103
+ END;
34104
+
34105
+ IF TG_OP = 'INSERT' THEN
34106
+ RETURN NEW;
34107
+ END IF;
34108
+ RETURN OLD;
34109
+ END;
34110
+ $$;
34111
+
34112
+ DROP TRIGGER IF EXISTS sync_contact_project_membership_state_from_legacy
34113
+ ON contact_projects;
34114
+ CREATE TRIGGER sync_contact_project_membership_state_from_legacy
34115
+ AFTER INSERT OR DELETE ON contact_projects
34116
+ FOR EACH ROW EXECUTE FUNCTION sync_contact_project_membership_state_from_legacy();
34117
+
34118
+ INSERT INTO _migrations (version) VALUES (14) ON CONFLICT DO NOTHING;
33719
34119
  `
33720
34120
  ];
33721
34121
  });
@@ -33818,6 +34218,7 @@ var init_cloud = __esm(() => {
33818
34218
  });
33819
34219
 
33820
34220
  // src/server/pg-store.ts
34221
+ import { createHash as createHash3 } from "crypto";
33821
34222
  function uuid3() {
33822
34223
  return crypto.randomUUID();
33823
34224
  }
@@ -33852,6 +34253,45 @@ function newUuid() {
33852
34253
  function nowIso() {
33853
34254
  return new Date().toISOString();
33854
34255
  }
34256
+ function contactProjectDigest(value) {
34257
+ return createHash3("sha256").update(value).digest("hex");
34258
+ }
34259
+ function contactProjectMembershipVersion(row) {
34260
+ return `cpmv_${contactProjectDigest(JSON.stringify([
34261
+ row.contact_id,
34262
+ row.project_id,
34263
+ Boolean(row.linked),
34264
+ Number(row.revision)
34265
+ ])).slice(0, 32)}`;
34266
+ }
34267
+ function contactProjectMembershipSnapshot(row) {
34268
+ return {
34269
+ contact_id: row.contact_id,
34270
+ project_id: row.project_id,
34271
+ linked: Boolean(row.linked),
34272
+ version: contactProjectMembershipVersion(row)
34273
+ };
34274
+ }
34275
+ function parseMembershipSnapshot(value) {
34276
+ if (typeof value === "string")
34277
+ return JSON.parse(value);
34278
+ return value;
34279
+ }
34280
+ function requiredMembershipValue(value, name) {
34281
+ const normalized = value.trim();
34282
+ if (!normalized)
34283
+ throw new Error(`${name} is required`);
34284
+ return normalized;
34285
+ }
34286
+ function normalizeMembershipMutationInput(input) {
34287
+ return {
34288
+ contact_id: requiredMembershipValue(input.contact_id, "contact_id"),
34289
+ project_id: requiredMembershipValue(input.project_id, "project_id"),
34290
+ operation_id: requiredMembershipValue(input.operation_id, "operation_id"),
34291
+ step_id: requiredMembershipValue(input.step_id, "step_id"),
34292
+ expected_version: requiredMembershipValue(input.expected_version, "expected_version")
34293
+ };
34294
+ }
33855
34295
  function parseJson(value) {
33856
34296
  if (!value)
33857
34297
  return {};
@@ -33919,6 +34359,29 @@ function mapTag(row) {
33919
34359
  created_at: iso(row.created_at)
33920
34360
  };
33921
34361
  }
34362
+ function mapEmail(row) {
34363
+ return {
34364
+ id: String(row["id"]),
34365
+ contact_id: row["contact_id"] ?? null,
34366
+ company_id: row["company_id"] ?? null,
34367
+ address: String(row["address"]),
34368
+ type: row["type"],
34369
+ is_primary: Boolean(row["is_primary"]),
34370
+ created_at: iso(row["created_at"])
34371
+ };
34372
+ }
34373
+ function mapPhone(row) {
34374
+ return {
34375
+ id: String(row["id"]),
34376
+ contact_id: row["contact_id"] ?? null,
34377
+ company_id: row["company_id"] ?? null,
34378
+ number: String(row["number"]),
34379
+ country_code: row["country_code"] ?? null,
34380
+ type: row["type"],
34381
+ is_primary: Boolean(row["is_primary"]),
34382
+ created_at: iso(row["created_at"])
34383
+ };
34384
+ }
33922
34385
 
33923
34386
  class ContactsPgStore {
33924
34387
  client;
@@ -33938,6 +34401,45 @@ class ContactsPgStore {
33938
34401
  tagsByContactId.get(row.contact_id)?.push(mapTag(row));
33939
34402
  return contacts.map((contact) => ({ ...contact, tags: tagsByContactId.get(contact.id) ?? [] }));
33940
34403
  }
34404
+ async attachContactMethods(contacts) {
34405
+ if (contacts.length === 0)
34406
+ return [];
34407
+ const ids = contacts.map((contact) => contact.id);
34408
+ const emailsByContactId = new Map(ids.map((id) => [id, []]));
34409
+ const phonesByContactId = new Map(ids.map((id) => [id, []]));
34410
+ const [emailRows, phoneRows] = await Promise.all([
34411
+ this.client.many(`SELECT * FROM emails WHERE contact_id = ANY($1::text[]) ORDER BY created_at ASC`, [ids]),
34412
+ this.client.many(`SELECT * FROM phones WHERE contact_id = ANY($1::text[]) ORDER BY created_at ASC`, [ids])
34413
+ ]);
34414
+ for (const row of emailRows)
34415
+ emailsByContactId.get(String(row["contact_id"]))?.push(mapEmail(row));
34416
+ for (const row of phoneRows)
34417
+ phonesByContactId.get(String(row["contact_id"]))?.push(mapPhone(row));
34418
+ return contacts.map((contact) => ({
34419
+ ...contact,
34420
+ emails: emailsByContactId.get(contact.id) ?? [],
34421
+ phones: phonesByContactId.get(contact.id) ?? []
34422
+ }));
34423
+ }
34424
+ async attachContactDetails(contacts) {
34425
+ return this.attachContactMethods(await this.attachTags(contacts));
34426
+ }
34427
+ async insertContactMethods(contactId, emails, phones) {
34428
+ for (const email2 of emails ?? []) {
34429
+ await this.client.query(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary)
34430
+ SELECT $1, $2, NULL, $3, $4, $5
34431
+ WHERE NOT EXISTS (
34432
+ SELECT 1 FROM emails WHERE contact_id = $2 AND LOWER(address) = LOWER($3)
34433
+ )`, [uuid3(), contactId, email2.address, email2.type ?? "work", email2.is_primary ?? false]);
34434
+ }
34435
+ for (const phone of phones ?? []) {
34436
+ await this.client.query(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary)
34437
+ SELECT $1, $2, NULL, $3, $4, $5, $6
34438
+ WHERE NOT EXISTS (
34439
+ SELECT 1 FROM phones WHERE contact_id = $2 AND number = $3
34440
+ )`, [uuid3(), contactId, phone.number, phone.country_code ?? null, phone.type ?? "mobile", phone.is_primary ?? false]);
34441
+ }
34442
+ }
33941
34443
  async listContacts(filter = {}) {
33942
34444
  const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
33943
34445
  const offset = Math.max(filter.offset ?? 0, 0);
@@ -33963,13 +34465,13 @@ class ContactsPgStore {
33963
34465
  const countRow = await this.client.get(`SELECT COUNT(*)::text AS count FROM contacts ${whereSql}`, params);
33964
34466
  params.push(limit, offset);
33965
34467
  const rows = await this.client.many(`SELECT * FROM contacts ${whereSql} ORDER BY display_name ASC LIMIT $${params.length - 1} OFFSET $${params.length}`, params);
33966
- return { contacts: await this.attachTags(rows.map(mapContact)), count: Number(countRow?.count ?? rows.length) };
34468
+ return { contacts: await this.attachContactDetails(rows.map(mapContact)), count: Number(countRow?.count ?? rows.length) };
33967
34469
  }
33968
34470
  async getContact(id) {
33969
34471
  const row = await this.client.get(`SELECT * FROM contacts WHERE id = $1`, [id]);
33970
34472
  if (!row)
33971
34473
  return null;
33972
- return (await this.attachTags([mapContact(row)]))[0];
34474
+ return (await this.attachContactDetails([mapContact(row)]))[0];
33973
34475
  }
33974
34476
  async createContact(input) {
33975
34477
  const id = uuid3();
@@ -34005,7 +34507,8 @@ class ContactsPgStore {
34005
34507
  input.priority ?? 3,
34006
34508
  input.timezone ?? null
34007
34509
  ]);
34008
- return (await this.attachTags([mapContact(row)]))[0];
34510
+ await this.insertContactMethods(id, input.emails, input.phones);
34511
+ return (await this.attachContactDetails([mapContact(row)]))[0];
34009
34512
  }
34010
34513
  async updateContact(id, input) {
34011
34514
  const allowed = {};
@@ -34040,17 +34543,192 @@ class ContactsPgStore {
34040
34543
  allowed.custom_fields = JSON.stringify(input.custom_fields);
34041
34544
  }
34042
34545
  const keys = Object.keys(allowed);
34043
- if (keys.length === 0)
34546
+ const hasMethodAppends = Boolean(input.emails_add?.length || input.phones_add?.length);
34547
+ if (keys.length === 0 && !hasMethodAppends)
34044
34548
  return this.getContact(id);
34045
34549
  const sets = keys.map((k, i) => `${k} = $${i + 2}`);
34046
34550
  sets.push(`updated_at = NOW()`);
34047
34551
  const row = await this.client.get(`UPDATE contacts SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, [id, ...keys.map((k) => allowed[k])]);
34048
- return row ? (await this.attachTags([mapContact(row)]))[0] : null;
34552
+ if (!row)
34553
+ return null;
34554
+ await this.insertContactMethods(id, input.emails_add, input.phones_add);
34555
+ return (await this.attachContactDetails([mapContact(row)]))[0];
34049
34556
  }
34050
34557
  async deleteContact(id) {
34051
34558
  const result = await this.client.query(`DELETE FROM contacts WHERE id = $1`, [id]);
34052
34559
  return result.rowCount > 0;
34053
34560
  }
34561
+ async linkContactToProject(contactId, projectId) {
34562
+ await this.client.transaction(async (client) => {
34563
+ await this.transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, true);
34564
+ });
34565
+ }
34566
+ async unlinkContactFromProject(contactId, projectId) {
34567
+ return this.client.transaction((client) => this.transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, false));
34568
+ }
34569
+ async getContactProjectIds(contactId) {
34570
+ const rows = await this.client.many(`SELECT project_id
34571
+ FROM contact_projects
34572
+ WHERE contact_id = $1
34573
+ ORDER BY project_id ASC`, [contactId]);
34574
+ return rows.map((row) => row.project_id);
34575
+ }
34576
+ async setContactProjects(contactId, projectIds) {
34577
+ const uniqueProjectIds = [...new Set(projectIds.map((projectId) => requiredMembershipValue(projectId, "project_id")))];
34578
+ await this.client.transaction(async (client) => {
34579
+ const current = await client.many(`SELECT project_id FROM contact_projects WHERE contact_id = $1`, [contactId]);
34580
+ const desired = new Set(uniqueProjectIds);
34581
+ const population = new Set([...current.map((row) => row.project_id), ...uniqueProjectIds]);
34582
+ for (const projectId of population) {
34583
+ await this.transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, desired.has(projectId));
34584
+ }
34585
+ });
34586
+ return uniqueProjectIds;
34587
+ }
34588
+ async listContactIdsByProject(projectId) {
34589
+ const rows = await this.client.many(`SELECT contact_id
34590
+ FROM contact_projects
34591
+ WHERE project_id = $1
34592
+ ORDER BY contact_id ASC`, [projectId]);
34593
+ return rows.map((row) => row.contact_id);
34594
+ }
34595
+ async contactProjectMembershipState(client, contactId, projectId, forUpdate = false) {
34596
+ let row = await client.get(`SELECT contact_id, project_id, linked, revision
34597
+ FROM contact_project_membership_states
34598
+ WHERE contact_id = $1 AND project_id = $2${forUpdate ? " FOR UPDATE" : ""}`, [contactId, projectId]);
34599
+ if (row)
34600
+ return row;
34601
+ const linked = Boolean(await client.get(`SELECT 1 AS present FROM contact_projects WHERE contact_id = $1 AND project_id = $2`, [contactId, projectId]));
34602
+ if (!forUpdate) {
34603
+ return { contact_id: contactId, project_id: projectId, linked, revision: 0 };
34604
+ }
34605
+ await client.execute(`INSERT INTO contact_project_membership_states
34606
+ (contact_id, project_id, linked, revision, updated_at)
34607
+ VALUES ($1, $2, $3, 0, NOW())
34608
+ ON CONFLICT (contact_id, project_id) DO NOTHING`, [contactId, projectId, linked]);
34609
+ row = await client.get(`SELECT contact_id, project_id, linked, revision
34610
+ FROM contact_project_membership_states
34611
+ WHERE contact_id = $1 AND project_id = $2
34612
+ FOR UPDATE`, [contactId, projectId]);
34613
+ if (!row)
34614
+ throw new Error("failed to initialize contact project membership state");
34615
+ return row;
34616
+ }
34617
+ async transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, linked) {
34618
+ const normalizedContactId = requiredMembershipValue(contactId, "contact_id");
34619
+ const normalizedProjectId = requiredMembershipValue(projectId, "project_id");
34620
+ const before = await this.contactProjectMembershipState(client, normalizedContactId, normalizedProjectId, true);
34621
+ const changed = Boolean(before.linked) !== linked;
34622
+ const revision = Number(before.revision) + (changed ? 1 : 0);
34623
+ await client.execute(`UPDATE contact_project_membership_states
34624
+ SET linked = $3, revision = $4, updated_at = NOW()
34625
+ WHERE contact_id = $1 AND project_id = $2`, [normalizedContactId, normalizedProjectId, linked, revision]);
34626
+ if (linked) {
34627
+ await client.execute(`INSERT INTO contact_projects (contact_id, project_id) VALUES ($1, $2)
34628
+ ON CONFLICT (contact_id, project_id) DO NOTHING`, [normalizedContactId, normalizedProjectId]);
34629
+ } else {
34630
+ await client.execute(`DELETE FROM contact_projects WHERE contact_id = $1 AND project_id = $2`, [normalizedContactId, normalizedProjectId]);
34631
+ }
34632
+ return changed;
34633
+ }
34634
+ async readContactProjectMembership(contactId, projectId) {
34635
+ return contactProjectMembershipSnapshot(await this.contactProjectMembershipState(this.client, contactId, projectId));
34636
+ }
34637
+ async listContactProjectMemberships(projectId, maxItems) {
34638
+ if (!Number.isInteger(maxItems) || maxItems < 1)
34639
+ throw new Error("max_items must be a positive integer");
34640
+ const rows = await this.client.many(`SELECT cp.contact_id, cp.project_id,
34641
+ COALESCE(state.linked, TRUE) AS linked,
34642
+ COALESCE(state.revision, 0) AS revision
34643
+ FROM contact_projects cp
34644
+ LEFT JOIN contact_project_membership_states state
34645
+ ON state.contact_id = cp.contact_id AND state.project_id = cp.project_id
34646
+ WHERE cp.project_id = $1
34647
+ ORDER BY cp.contact_id ASC
34648
+ LIMIT $2`, [projectId, maxItems + 1]);
34649
+ if (rows.length > maxItems) {
34650
+ throw new Error(`contact project membership collection exceeds max_items=${maxItems}`);
34651
+ }
34652
+ return {
34653
+ project_id: projectId,
34654
+ contact_ids: rows.map((row) => row.contact_id),
34655
+ complete: true,
34656
+ membership_revision: `cpml_${contactProjectDigest(JSON.stringify(rows.map(contactProjectMembershipSnapshot))).slice(0, 32)}`
34657
+ };
34658
+ }
34659
+ async mutateContactProjectMembership(direction, rawInput) {
34660
+ const input = normalizeMembershipMutationInput(rawInput);
34661
+ return this.client.transaction(async (client) => {
34662
+ await client.execute(`SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, [input.operation_id, input.step_id]);
34663
+ const receipt = await client.get(`SELECT direction, contact_id, project_id, operation_id, step_id, expected_version,
34664
+ before_json, after_json, receipt_id
34665
+ FROM contact_project_membership_receipts
34666
+ WHERE operation_id = $1 AND step_id = $2`, [input.operation_id, input.step_id]);
34667
+ if (receipt) {
34668
+ if (receipt.direction !== direction || receipt.contact_id !== input.contact_id || receipt.project_id !== input.project_id || receipt.expected_version !== input.expected_version) {
34669
+ throw new ContactProjectMembershipConflictError("operation_id/step_id already accepted for a different contact-project membership mutation");
34670
+ }
34671
+ return {
34672
+ outcome: "duplicate_of_accepted",
34673
+ operation_id: receipt.operation_id,
34674
+ step_id: receipt.step_id,
34675
+ before: parseMembershipSnapshot(receipt.before_json),
34676
+ after: parseMembershipSnapshot(receipt.after_json),
34677
+ receipt_id: receipt.receipt_id
34678
+ };
34679
+ }
34680
+ const contact = await client.get(`SELECT id FROM contacts WHERE id = $1`, [input.contact_id]);
34681
+ if (!contact)
34682
+ throw new Error(`contact not found: ${input.contact_id}`);
34683
+ const beforeRow = await this.contactProjectMembershipState(client, input.contact_id, input.project_id, true);
34684
+ const before = contactProjectMembershipSnapshot(beforeRow);
34685
+ if (before.version !== input.expected_version) {
34686
+ throw new ContactProjectMembershipConflictError(`contact project membership expected_version conflict: expected ${input.expected_version}, current ${before.version}`);
34687
+ }
34688
+ const desiredLinked = direction === "attach";
34689
+ const changed = before.linked !== desiredLinked;
34690
+ const revision = Number(beforeRow.revision) + (changed ? 1 : 0);
34691
+ const afterRow = { ...beforeRow, linked: desiredLinked, revision };
34692
+ await client.execute(`UPDATE contact_project_membership_states
34693
+ SET linked = $3, revision = $4, updated_at = NOW()
34694
+ WHERE contact_id = $1 AND project_id = $2`, [input.contact_id, input.project_id, desiredLinked, revision]);
34695
+ if (desiredLinked) {
34696
+ await client.execute(`INSERT INTO contact_projects (contact_id, project_id) VALUES ($1, $2)
34697
+ ON CONFLICT (contact_id, project_id) DO NOTHING`, [input.contact_id, input.project_id]);
34698
+ } else {
34699
+ await client.execute(`DELETE FROM contact_projects WHERE contact_id = $1 AND project_id = $2`, [input.contact_id, input.project_id]);
34700
+ }
34701
+ const after = contactProjectMembershipSnapshot(afterRow);
34702
+ const id = `cpmr_${contactProjectDigest(JSON.stringify([
34703
+ input.operation_id,
34704
+ input.step_id,
34705
+ input.contact_id,
34706
+ input.project_id
34707
+ ])).slice(0, 32)}`;
34708
+ await client.execute(`INSERT INTO contact_project_membership_receipts (
34709
+ receipt_id, direction, contact_id, project_id, operation_id, step_id,
34710
+ expected_version, before_json, after_json
34711
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb)`, [
34712
+ id,
34713
+ direction,
34714
+ input.contact_id,
34715
+ input.project_id,
34716
+ input.operation_id,
34717
+ input.step_id,
34718
+ input.expected_version,
34719
+ JSON.stringify(before),
34720
+ JSON.stringify(after)
34721
+ ]);
34722
+ return {
34723
+ outcome: changed ? "accepted" : "duplicate_of_accepted",
34724
+ operation_id: input.operation_id,
34725
+ step_id: input.step_id,
34726
+ before,
34727
+ after,
34728
+ receipt_id: id
34729
+ };
34730
+ });
34731
+ }
34054
34732
  async listCompanies(filter = {}) {
34055
34733
  const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
34056
34734
  const offset = Math.max(filter.offset ?? 0, 0);
@@ -35488,6 +36166,9 @@ function getContactsPgStore(client) {
35488
36166
  return cachedStore2;
35489
36167
  }
35490
36168
  var cachedStore2 = null;
36169
+ var init_pg_store = __esm(() => {
36170
+ init_project_memberships();
36171
+ });
35491
36172
 
35492
36173
  // src/server/v1.ts
35493
36174
  function json5(body, status = 200) {
@@ -35516,6 +36197,84 @@ function contactListFilterFromUrl(url) {
35516
36197
  ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
35517
36198
  };
35518
36199
  }
36200
+ async function handleContactProjectsRoute(req, method, segments, store) {
36201
+ const resource = segments[1];
36202
+ const id = segments[2];
36203
+ const sub = segments[3];
36204
+ if (resource === "contacts" && id && sub === "projects") {
36205
+ const contact = await store.getContact(id);
36206
+ if (!contact)
36207
+ return error2(404, "contact not found");
36208
+ const projectId = segments[4];
36209
+ if (projectId) {
36210
+ if (method === "PUT") {
36211
+ await store.linkContactToProject(id, projectId);
36212
+ return json5({ attached: true, contact_id: id, project_id: projectId });
36213
+ }
36214
+ if (method === "DELETE") {
36215
+ const removed = await store.unlinkContactFromProject(id, projectId);
36216
+ return json5({ removed, contact_id: id, project_id: projectId });
36217
+ }
36218
+ return error2(405, `method ${method} not allowed on /v1/contacts/:contact_id/projects/:project_id`);
36219
+ }
36220
+ if (method === "GET") {
36221
+ return json5({ contact_id: id, project_ids: await store.getContactProjectIds(id) });
36222
+ }
36223
+ if (method === "PUT") {
36224
+ const body = await readJson(req);
36225
+ if (!body || !Array.isArray(body.project_ids) || !body.project_ids.every((value) => typeof value === "string" && value.trim().length > 0)) {
36226
+ return error2(400, "project_ids must be an array of non-empty strings");
36227
+ }
36228
+ const projectIds = await store.setContactProjects(id, body.project_ids);
36229
+ return json5({ contact_id: id, project_ids: projectIds });
36230
+ }
36231
+ return error2(405, `method ${method} not allowed on /v1/contacts/:contact_id/projects`);
36232
+ }
36233
+ if (resource === "projects" && id && sub === "contacts") {
36234
+ if (method === "GET") {
36235
+ return json5({ project_id: id, contact_ids: await store.listContactIdsByProject(id) });
36236
+ }
36237
+ return error2(405, `method ${method} not allowed on /v1/projects/:project_id/contacts`);
36238
+ }
36239
+ if (resource === "projects" && id && sub === "contact-memberships") {
36240
+ const contactId = segments[4];
36241
+ const action = segments[5];
36242
+ if (!contactId) {
36243
+ if (method !== "GET") {
36244
+ return error2(405, `method ${method} not allowed on /v1/projects/:project_id/contact-memberships`);
36245
+ }
36246
+ const rawMaxItems = new URL(req.url).searchParams.get("max_items");
36247
+ const maxItems = rawMaxItems === null ? 1000 : Number(rawMaxItems);
36248
+ if (!Number.isInteger(maxItems) || maxItems < 1)
36249
+ return error2(400, "max_items must be a positive integer");
36250
+ return json5(await store.listContactProjectMemberships(id, maxItems));
36251
+ }
36252
+ if (!action) {
36253
+ if (method !== "GET") {
36254
+ return error2(405, `method ${method} not allowed on /v1/projects/:project_id/contact-memberships/:contact_id`);
36255
+ }
36256
+ const contact = await store.getContact(contactId);
36257
+ if (!contact)
36258
+ return error2(404, "contact not found");
36259
+ return json5(await store.readContactProjectMembership(contactId, id));
36260
+ }
36261
+ if ((action === "attach" || action === "detach") && method === "POST") {
36262
+ const body = await readJson(req);
36263
+ if (!body || typeof body.operation_id !== "string" || typeof body.step_id !== "string" || typeof body.expected_version !== "string" || body.operation_id.trim().length === 0 || body.step_id.trim().length === 0 || body.expected_version.trim().length === 0) {
36264
+ return error2(400, "operation_id, step_id, and expected_version are required non-empty strings");
36265
+ }
36266
+ return json5(await store.mutateContactProjectMembership(action, {
36267
+ contact_id: contactId,
36268
+ project_id: id,
36269
+ operation_id: body.operation_id,
36270
+ step_id: body.step_id,
36271
+ expected_version: body.expected_version
36272
+ }));
36273
+ }
36274
+ return error2(405, `method ${method} not allowed on contact-project membership route`);
36275
+ }
36276
+ return null;
36277
+ }
35519
36278
  async function handleV1Request(req, url) {
35520
36279
  const path = url.pathname;
35521
36280
  if (path !== "/v1" && !path.startsWith("/v1/"))
@@ -35550,6 +36309,9 @@ async function handleV1Request(req, url) {
35550
36309
  return v === null ? undefined : Number(v);
35551
36310
  };
35552
36311
  try {
36312
+ const projectLinks = await handleContactProjectsRoute(req, method, segments, store);
36313
+ if (projectLinks)
36314
+ return projectLinks;
35553
36315
  if (resource === "contacts" && id && sub) {
35554
36316
  if (sub === "tags") {
35555
36317
  const tagId = segments[4];
@@ -36251,6 +37013,8 @@ async function handleV1Request(req, url) {
36251
37013
  return error2(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
36252
37014
  } catch (e) {
36253
37015
  const msg = e.message || "internal error";
37016
+ if (e instanceof ContactProjectMembershipConflictError)
37017
+ return error2(409, msg);
36254
37018
  if (/violates|constraint|invalid input|duplicate key/i.test(msg))
36255
37019
  return error2(400, msg);
36256
37020
  return error2(500, msg);
@@ -36258,7 +37022,9 @@ async function handleV1Request(req, url) {
36258
37022
  }
36259
37023
  var JSON_HEADERS;
36260
37024
  var init_v1 = __esm(() => {
37025
+ init_project_memberships();
36261
37026
  init_cloud();
37027
+ init_pg_store();
36262
37028
  JSON_HEADERS = { "Content-Type": "application/json" };
36263
37029
  });
36264
37030
 
@@ -36383,6 +37149,58 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
36383
37149
  color: { type: "string" },
36384
37150
  description: { type: "string" }
36385
37151
  }
37152
+ },
37153
+ ProjectIdsInput: {
37154
+ type: "object",
37155
+ required: ["project_ids"],
37156
+ properties: {
37157
+ project_ids: {
37158
+ type: "array",
37159
+ items: { type: "string", minLength: 1 },
37160
+ uniqueItems: true
37161
+ }
37162
+ }
37163
+ },
37164
+ ContactProjectMembershipSnapshot: {
37165
+ type: "object",
37166
+ required: ["contact_id", "project_id", "linked", "version"],
37167
+ properties: {
37168
+ contact_id: { type: "string" },
37169
+ project_id: { type: "string" },
37170
+ linked: { type: "boolean" },
37171
+ version: { type: "string" }
37172
+ }
37173
+ },
37174
+ ContactProjectMembershipMutationInput: {
37175
+ type: "object",
37176
+ required: ["operation_id", "step_id", "expected_version"],
37177
+ properties: {
37178
+ operation_id: { type: "string", minLength: 1 },
37179
+ step_id: { type: "string", minLength: 1 },
37180
+ expected_version: { type: "string", minLength: 1 }
37181
+ }
37182
+ },
37183
+ ContactProjectMembershipMutationResult: {
37184
+ type: "object",
37185
+ required: ["outcome", "operation_id", "step_id", "before", "after", "receipt_id"],
37186
+ properties: {
37187
+ outcome: { type: "string", enum: ["accepted", "duplicate_of_accepted"] },
37188
+ operation_id: { type: "string" },
37189
+ step_id: { type: "string" },
37190
+ before: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" },
37191
+ after: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" },
37192
+ receipt_id: { type: "string" }
37193
+ }
37194
+ },
37195
+ ContactProjectMembershipListResult: {
37196
+ type: "object",
37197
+ required: ["project_id", "contact_ids", "complete", "membership_revision"],
37198
+ properties: {
37199
+ project_id: { type: "string" },
37200
+ contact_ids: { type: "array", items: { type: "string" } },
37201
+ complete: { type: "boolean", const: true },
37202
+ membership_revision: { type: "string" }
37203
+ }
36386
37204
  }
36387
37205
  }
36388
37206
  },
@@ -36583,6 +37401,167 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
36583
37401
  })
36584
37402
  }
36585
37403
  },
37404
+ "/v1/contacts/{contact_id}/projects": {
37405
+ get: {
37406
+ operationId: "getContactProjectIds",
37407
+ summary: "List project ids attached to a contact",
37408
+ parameters: [
37409
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
37410
+ ],
37411
+ responses: objResponse({
37412
+ contact_id: { type: "string" },
37413
+ project_ids: { type: "array", items: { type: "string" } }
37414
+ })
37415
+ },
37416
+ put: {
37417
+ operationId: "setContactProjects",
37418
+ summary: "Atomically replace a contact's project memberships",
37419
+ parameters: [
37420
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
37421
+ ],
37422
+ requestBody: {
37423
+ required: true,
37424
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectIdsInput" } } }
37425
+ },
37426
+ responses: objResponse({
37427
+ contact_id: { type: "string" },
37428
+ project_ids: { type: "array", items: { type: "string" } }
37429
+ })
37430
+ }
37431
+ },
37432
+ "/v1/contacts/{contact_id}/projects/{project_id}": {
37433
+ put: {
37434
+ operationId: "linkContactToProject",
37435
+ summary: "Attach a contact to a project idempotently",
37436
+ parameters: [
37437
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } },
37438
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
37439
+ ],
37440
+ responses: objResponse({
37441
+ attached: { type: "boolean" },
37442
+ contact_id: { type: "string" },
37443
+ project_id: { type: "string" }
37444
+ })
37445
+ },
37446
+ delete: {
37447
+ operationId: "unlinkContactFromProject",
37448
+ summary: "Detach a contact from a project",
37449
+ parameters: [
37450
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } },
37451
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
37452
+ ],
37453
+ responses: objResponse({
37454
+ removed: { type: "boolean" },
37455
+ contact_id: { type: "string" },
37456
+ project_id: { type: "string" }
37457
+ })
37458
+ }
37459
+ },
37460
+ "/v1/projects/{project_id}/contacts": {
37461
+ get: {
37462
+ operationId: "listContactIdsByProject",
37463
+ summary: "List contact ids attached to a project",
37464
+ parameters: [
37465
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
37466
+ ],
37467
+ responses: objResponse({
37468
+ project_id: { type: "string" },
37469
+ contact_ids: { type: "array", items: { type: "string" } }
37470
+ })
37471
+ }
37472
+ },
37473
+ "/v1/projects/{project_id}/contact-memberships": {
37474
+ get: {
37475
+ operationId: "listContactProjectMemberships",
37476
+ summary: "List the complete authoritative contact membership collection for a project",
37477
+ parameters: [
37478
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
37479
+ { name: "max_items", in: "query", required: true, schema: { type: "integer", minimum: 1 } }
37480
+ ],
37481
+ responses: {
37482
+ "200": {
37483
+ content: {
37484
+ "application/json": {
37485
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipListResult" }
37486
+ }
37487
+ }
37488
+ }
37489
+ }
37490
+ }
37491
+ },
37492
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}": {
37493
+ get: {
37494
+ operationId: "readContactProjectMembership",
37495
+ summary: "Read one authoritative contact-project membership snapshot",
37496
+ parameters: [
37497
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
37498
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
37499
+ ],
37500
+ responses: {
37501
+ "200": {
37502
+ content: {
37503
+ "application/json": {
37504
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" }
37505
+ }
37506
+ }
37507
+ }
37508
+ }
37509
+ }
37510
+ },
37511
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}/attach": {
37512
+ post: {
37513
+ operationId: "attachContactProjectMembership",
37514
+ summary: "Attach a contact to a project under expected-version CAS with a replay-safe receipt",
37515
+ parameters: [
37516
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
37517
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
37518
+ ],
37519
+ requestBody: {
37520
+ required: true,
37521
+ content: {
37522
+ "application/json": {
37523
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationInput" }
37524
+ }
37525
+ }
37526
+ },
37527
+ responses: {
37528
+ "200": {
37529
+ content: {
37530
+ "application/json": {
37531
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationResult" }
37532
+ }
37533
+ }
37534
+ }
37535
+ }
37536
+ }
37537
+ },
37538
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}/detach": {
37539
+ post: {
37540
+ operationId: "detachContactProjectMembership",
37541
+ summary: "Detach a contact from a project under expected-version CAS with a replay-safe receipt",
37542
+ parameters: [
37543
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
37544
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
37545
+ ],
37546
+ requestBody: {
37547
+ required: true,
37548
+ content: {
37549
+ "application/json": {
37550
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationInput" }
37551
+ }
37552
+ }
37553
+ },
37554
+ responses: {
37555
+ "200": {
37556
+ content: {
37557
+ "application/json": {
37558
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationResult" }
37559
+ }
37560
+ }
37561
+ }
37562
+ }
37563
+ }
37564
+ },
36586
37565
  "/v1/stats": {
36587
37566
  get: {
36588
37567
  operationId: "getStats",
@@ -38649,6 +39628,41 @@ Group not found: ${groupId}
38649
39628
  await store.removeContactFromGroup(contactId, groupId);
38650
39629
  console.log(chalk2.green(`
38651
39630
  \u2713 Removed ${contact?.display_name ?? contactId} from group ${group.name}
39631
+ `));
39632
+ });
39633
+ const projectsCmd = program2.command("projects").description("Manage contact project links");
39634
+ projectsCmd.command("attach <contact-id> <project-id>").description("Attach a contact to a project idempotently").action(async (contactId, projectId) => {
39635
+ const store = getStore();
39636
+ await store.linkContactToProject(contactId, projectId);
39637
+ console.log(chalk2.green(`
39638
+ \u2713 Attached ${contactId} to project ${projectId}
39639
+ `));
39640
+ });
39641
+ projectsCmd.command("list <contact-id>").description("List project ids attached to a contact").option("-j, --json", "Output JSON").action(async (contactId, opts) => {
39642
+ const store = getStore();
39643
+ const projectIds = await store.getContactProjectIds(contactId);
39644
+ if (opts.json) {
39645
+ console.log(JSON.stringify({ contact_id: contactId, project_ids: projectIds }, null, 2));
39646
+ return;
39647
+ }
39648
+ if (projectIds.length === 0) {
39649
+ console.log(chalk2.gray(`
39650
+ No project links found for ${contactId}.
39651
+ `));
39652
+ return;
39653
+ }
39654
+ console.log();
39655
+ for (const projectId of projectIds)
39656
+ console.log(` ${projectId}`);
39657
+ console.log(chalk2.gray(`
39658
+ ${projectIds.length} project link(s) for ${contactId}
39659
+ `));
39660
+ });
39661
+ projectsCmd.command("detach <contact-id> <project-id>").description("Detach a contact from a project").action(async (contactId, projectId) => {
39662
+ const store = getStore();
39663
+ await store.unlinkContactFromProject(contactId, projectId);
39664
+ console.log(chalk2.green(`
39665
+ \u2713 Detached ${contactId} from project ${projectId}
38652
39666
  `));
38653
39667
  });
38654
39668
  program2.command("init").description("Show setup info, stats, and configuration").action(async () => {