@hasna/contacts 0.6.33 → 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.
@@ -176,7 +176,10 @@ function uuid() {
176
176
  return crypto.randomUUID();
177
177
  }
178
178
  function now() {
179
- return new Date().toISOString();
179
+ const currentMs = Date.now();
180
+ const nextMs = currentMs > lastNowMs ? currentMs : lastNowMs + 1;
181
+ lastNowMs = nextMs;
182
+ return new Date(nextMs).toISOString();
180
183
  }
181
184
  function quoteIdentifier(identifier) {
182
185
  return `"${identifier.replaceAll('"', '""')}"`;
@@ -214,7 +217,7 @@ function runMigrations(db) {
214
217
  db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${i})`);
215
218
  }
216
219
  }
217
- var MIGRATIONS, _db = null;
220
+ var MIGRATIONS, _db = null, lastNowMs = 0;
218
221
  var init_database = __esm(() => {
219
222
  init_sqlite_adapter();
220
223
  init_paths();
@@ -786,6 +789,81 @@ var init_database = __esm(() => {
786
789
  );
787
790
 
788
791
  CREATE INDEX IF NOT EXISTS idx_contacts_tombstones_deleted_at ON _contacts_tombstones(deleted_at);
792
+ `,
793
+ `
794
+ CREATE TABLE IF NOT EXISTS contact_project_membership_states (
795
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
796
+ project_id TEXT NOT NULL,
797
+ linked INTEGER NOT NULL CHECK(linked IN (0, 1)),
798
+ revision INTEGER NOT NULL DEFAULT 0 CHECK(revision >= 0),
799
+ updated_at TEXT NOT NULL,
800
+ PRIMARY KEY (contact_id, project_id)
801
+ );
802
+
803
+ INSERT OR IGNORE INTO contact_project_membership_states
804
+ (contact_id, project_id, linked, revision, updated_at)
805
+ SELECT contact_id, project_id, 1, 0, datetime('now')
806
+ FROM contact_projects;
807
+
808
+ CREATE TABLE IF NOT EXISTS contact_project_membership_receipts (
809
+ receipt_id TEXT PRIMARY KEY,
810
+ direction TEXT NOT NULL CHECK(direction IN ('attach', 'detach')),
811
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
812
+ project_id TEXT NOT NULL,
813
+ operation_id TEXT NOT NULL,
814
+ step_id TEXT NOT NULL,
815
+ expected_version TEXT NOT NULL,
816
+ before_json TEXT NOT NULL,
817
+ after_json TEXT NOT NULL,
818
+ created_at TEXT NOT NULL,
819
+ UNIQUE(operation_id, step_id)
820
+ );
821
+
822
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_states_project
823
+ ON contact_project_membership_states(project_id, linked, contact_id);
824
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_receipts_target
825
+ ON contact_project_membership_receipts(contact_id, project_id, created_at);
826
+
827
+ CREATE TRIGGER IF NOT EXISTS sync_contact_project_membership_state_after_insert
828
+ AFTER INSERT ON contact_projects
829
+ BEGIN
830
+ INSERT INTO contact_project_membership_states
831
+ (contact_id, project_id, linked, revision, updated_at)
832
+ VALUES (NEW.contact_id, NEW.project_id, 1, 1, datetime('now'))
833
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
834
+ linked = 1,
835
+ revision = CASE
836
+ WHEN contact_project_membership_states.linked = 1
837
+ THEN contact_project_membership_states.revision
838
+ ELSE contact_project_membership_states.revision + 1
839
+ END,
840
+ updated_at = CASE
841
+ WHEN contact_project_membership_states.linked = 1
842
+ THEN contact_project_membership_states.updated_at
843
+ ELSE datetime('now')
844
+ END;
845
+ END;
846
+
847
+ CREATE TRIGGER IF NOT EXISTS sync_contact_project_membership_state_after_delete
848
+ AFTER DELETE ON contact_projects
849
+ WHEN EXISTS (SELECT 1 FROM contacts WHERE id = OLD.contact_id)
850
+ BEGIN
851
+ INSERT INTO contact_project_membership_states
852
+ (contact_id, project_id, linked, revision, updated_at)
853
+ VALUES (OLD.contact_id, OLD.project_id, 0, 1, datetime('now'))
854
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
855
+ linked = 0,
856
+ revision = CASE
857
+ WHEN contact_project_membership_states.linked = 0
858
+ THEN contact_project_membership_states.revision
859
+ ELSE contact_project_membership_states.revision + 1
860
+ END,
861
+ updated_at = CASE
862
+ WHEN contact_project_membership_states.linked = 0
863
+ THEN contact_project_membership_states.updated_at
864
+ ELSE datetime('now')
865
+ END;
866
+ END;
789
867
  `
790
868
  ];
791
869
  });
@@ -883,6 +961,219 @@ var init_tombstones = __esm(() => {
883
961
  init_database();
884
962
  });
885
963
 
964
+ // src/types/project-memberships.ts
965
+ var ContactProjectMembershipConflictError;
966
+ var init_project_memberships = __esm(() => {
967
+ ContactProjectMembershipConflictError = class ContactProjectMembershipConflictError extends Error {
968
+ constructor(message) {
969
+ super(message);
970
+ this.name = "ContactProjectMembershipConflictError";
971
+ }
972
+ };
973
+ });
974
+
975
+ // src/db/project-memberships.ts
976
+ import { createHash } from "crypto";
977
+ function digest(value) {
978
+ return createHash("sha256").update(value).digest("hex");
979
+ }
980
+ function membershipVersion(contactId, projectId, linked, revision) {
981
+ return `cpmv_${digest(JSON.stringify([contactId, projectId, linked, revision])).slice(0, 32)}`;
982
+ }
983
+ function receiptId(input) {
984
+ return `cpmr_${digest(JSON.stringify([
985
+ input.operation_id,
986
+ input.step_id,
987
+ input.contact_id,
988
+ input.project_id
989
+ ])).slice(0, 32)}`;
990
+ }
991
+ function stateRow(contactId, projectId, db) {
992
+ const persisted = db.query(`SELECT contact_id, project_id, linked, revision
993
+ FROM contact_project_membership_states
994
+ WHERE contact_id = ? AND project_id = ?`).get(contactId, projectId);
995
+ if (persisted)
996
+ return persisted;
997
+ const linked = Boolean(db.query(`SELECT 1 AS present FROM contact_projects WHERE contact_id = ? AND project_id = ?`).get(contactId, projectId));
998
+ return { contact_id: contactId, project_id: projectId, linked: linked ? 1 : 0, revision: 0 };
999
+ }
1000
+ function snapshot(row) {
1001
+ const linked = Boolean(row.linked);
1002
+ return {
1003
+ contact_id: row.contact_id,
1004
+ project_id: row.project_id,
1005
+ linked,
1006
+ version: membershipVersion(row.contact_id, row.project_id, linked, row.revision)
1007
+ };
1008
+ }
1009
+ function required(value, name) {
1010
+ const normalized = value.trim();
1011
+ if (!normalized)
1012
+ throw new Error(`${name} is required`);
1013
+ return normalized;
1014
+ }
1015
+ function normalizeInput(input) {
1016
+ return {
1017
+ contact_id: required(input.contact_id, "contact_id"),
1018
+ project_id: required(input.project_id, "project_id"),
1019
+ operation_id: required(input.operation_id, "operation_id"),
1020
+ step_id: required(input.step_id, "step_id"),
1021
+ expected_version: required(input.expected_version, "expected_version")
1022
+ };
1023
+ }
1024
+ function parseSnapshot(value) {
1025
+ return JSON.parse(value);
1026
+ }
1027
+ function replay(row, direction, input) {
1028
+ if (row.direction !== direction || row.contact_id !== input.contact_id || row.project_id !== input.project_id || row.expected_version !== input.expected_version) {
1029
+ throw new ContactProjectMembershipConflictError(`operation_id/step_id already accepted for a different contact-project membership mutation`);
1030
+ }
1031
+ return {
1032
+ outcome: "duplicate_of_accepted",
1033
+ operation_id: row.operation_id,
1034
+ step_id: row.step_id,
1035
+ before: parseSnapshot(row.before_json),
1036
+ after: parseSnapshot(row.after_json),
1037
+ receipt_id: row.receipt_id
1038
+ };
1039
+ }
1040
+ function transitionWithoutReceipt(contactId, projectId, linked, db) {
1041
+ const beforeRow = stateRow(contactId, projectId, db);
1042
+ const changed = Boolean(beforeRow.linked) !== linked;
1043
+ const afterRow = {
1044
+ ...beforeRow,
1045
+ linked: linked ? 1 : 0,
1046
+ revision: changed ? beforeRow.revision + 1 : beforeRow.revision
1047
+ };
1048
+ const changedAt = now();
1049
+ db.run(`INSERT INTO contact_project_membership_states
1050
+ (contact_id, project_id, linked, revision, updated_at)
1051
+ VALUES (?, ?, ?, ?, ?)
1052
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
1053
+ linked = excluded.linked,
1054
+ revision = excluded.revision,
1055
+ updated_at = excluded.updated_at`, [contactId, projectId, afterRow.linked, afterRow.revision, changedAt]);
1056
+ if (linked) {
1057
+ db.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, projectId]);
1058
+ } else {
1059
+ db.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [contactId, projectId]);
1060
+ }
1061
+ return changed;
1062
+ }
1063
+ function setContactProjectMembershipWithoutReceipt(contactId, projectId, linked, db = getDatabase()) {
1064
+ const normalizedContactId = required(contactId, "contact_id");
1065
+ const normalizedProjectId = required(projectId, "project_id");
1066
+ return db.transaction(() => transitionWithoutReceipt(normalizedContactId, normalizedProjectId, linked, db));
1067
+ }
1068
+ function replaceContactProjectMembershipsWithoutReceipts(contactId, projectIds, db = getDatabase()) {
1069
+ const normalizedContactId = required(contactId, "contact_id");
1070
+ const normalizedProjectIds = [...new Set(projectIds.map((projectId) => required(projectId, "project_id")))];
1071
+ return db.transaction(() => {
1072
+ const currentRows = db.query(`SELECT project_id FROM contact_projects WHERE contact_id = ?`).all(normalizedContactId);
1073
+ const desired = new Set(normalizedProjectIds);
1074
+ const population = new Set([...currentRows.map((row) => row.project_id), ...normalizedProjectIds]);
1075
+ for (const projectId of population) {
1076
+ transitionWithoutReceipt(normalizedContactId, projectId, desired.has(projectId), db);
1077
+ }
1078
+ return normalizedProjectIds;
1079
+ });
1080
+ }
1081
+ function readContactProjectMembership(contactId, projectId, db = getDatabase()) {
1082
+ return snapshot(stateRow(required(contactId, "contact_id"), required(projectId, "project_id"), db));
1083
+ }
1084
+ function listContactProjectMemberships(projectId, maxItems, db = getDatabase()) {
1085
+ const normalizedProjectId = required(projectId, "project_id");
1086
+ if (!Number.isInteger(maxItems) || maxItems < 1)
1087
+ throw new Error("max_items must be a positive integer");
1088
+ const rows = db.query(`SELECT cp.contact_id, cp.project_id,
1089
+ COALESCE(state.linked, 1) AS linked,
1090
+ COALESCE(state.revision, 0) AS revision
1091
+ FROM contact_projects cp
1092
+ LEFT JOIN contact_project_membership_states state
1093
+ ON state.contact_id = cp.contact_id AND state.project_id = cp.project_id
1094
+ WHERE cp.project_id = ?
1095
+ ORDER BY cp.contact_id ASC
1096
+ LIMIT ?`).all(normalizedProjectId, maxItems + 1);
1097
+ if (rows.length > maxItems) {
1098
+ throw new Error(`contact project membership collection exceeds max_items=${maxItems}`);
1099
+ }
1100
+ const contactIds = rows.map((row) => row.contact_id);
1101
+ return {
1102
+ project_id: normalizedProjectId,
1103
+ contact_ids: contactIds,
1104
+ complete: true,
1105
+ membership_revision: `cpml_${digest(JSON.stringify(rows.map((row) => snapshot(row)))).slice(0, 32)}`
1106
+ };
1107
+ }
1108
+ function mutateContactProjectMembership(direction, rawInput, db = getDatabase()) {
1109
+ const input = normalizeInput(rawInput);
1110
+ return db.transaction(() => {
1111
+ const existingReceipt = db.query(`SELECT direction, contact_id, project_id, operation_id, step_id, expected_version,
1112
+ before_json, after_json, receipt_id
1113
+ FROM contact_project_membership_receipts
1114
+ WHERE operation_id = ? AND step_id = ?`).get(input.operation_id, input.step_id);
1115
+ if (existingReceipt)
1116
+ return replay(existingReceipt, direction, input);
1117
+ const contact = db.query(`SELECT id FROM contacts WHERE id = ?`).get(input.contact_id);
1118
+ if (!contact)
1119
+ throw new Error(`contact not found: ${input.contact_id}`);
1120
+ const beforeRow = stateRow(input.contact_id, input.project_id, db);
1121
+ const before = snapshot(beforeRow);
1122
+ if (before.version !== input.expected_version) {
1123
+ throw new ContactProjectMembershipConflictError(`contact project membership expected_version conflict: expected ${input.expected_version}, current ${before.version}`);
1124
+ }
1125
+ const desiredLinked = direction === "attach";
1126
+ const changed = before.linked !== desiredLinked;
1127
+ const afterRow = {
1128
+ ...beforeRow,
1129
+ linked: desiredLinked ? 1 : 0,
1130
+ revision: changed ? beforeRow.revision + 1 : beforeRow.revision
1131
+ };
1132
+ const changedAt = now();
1133
+ db.run(`INSERT INTO contact_project_membership_states
1134
+ (contact_id, project_id, linked, revision, updated_at)
1135
+ VALUES (?, ?, ?, ?, ?)
1136
+ ON CONFLICT(contact_id, project_id) DO UPDATE SET
1137
+ linked = excluded.linked,
1138
+ revision = excluded.revision,
1139
+ updated_at = excluded.updated_at`, [input.contact_id, input.project_id, afterRow.linked, afterRow.revision, changedAt]);
1140
+ if (desiredLinked) {
1141
+ db.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [input.contact_id, input.project_id]);
1142
+ } else {
1143
+ db.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [input.contact_id, input.project_id]);
1144
+ }
1145
+ const after = snapshot(afterRow);
1146
+ const id = receiptId(input);
1147
+ db.run(`INSERT INTO contact_project_membership_receipts (
1148
+ receipt_id, direction, contact_id, project_id, operation_id, step_id,
1149
+ expected_version, before_json, after_json, created_at
1150
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1151
+ id,
1152
+ direction,
1153
+ input.contact_id,
1154
+ input.project_id,
1155
+ input.operation_id,
1156
+ input.step_id,
1157
+ input.expected_version,
1158
+ JSON.stringify(before),
1159
+ JSON.stringify(after),
1160
+ changedAt
1161
+ ]);
1162
+ return {
1163
+ outcome: changed ? "accepted" : "duplicate_of_accepted",
1164
+ operation_id: input.operation_id,
1165
+ step_id: input.step_id,
1166
+ before,
1167
+ after,
1168
+ receipt_id: id
1169
+ };
1170
+ });
1171
+ }
1172
+ var init_project_memberships2 = __esm(() => {
1173
+ init_database();
1174
+ init_project_memberships();
1175
+ });
1176
+
886
1177
  // src/db/contacts.ts
887
1178
  var exports_contacts = {};
888
1179
  __export(exports_contacts, {
@@ -1481,34 +1772,32 @@ function autoLinkContactToCompany(contactId, db) {
1481
1772
  }
1482
1773
  function linkContactToProject(contactId, projectId, db) {
1483
1774
  const d = db || getDatabase();
1484
- d.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, projectId]);
1775
+ setContactProjectMembershipWithoutReceipt(contactId, projectId, true, d);
1485
1776
  }
1486
1777
  function unlinkContactFromProject(contactId, projectId, db) {
1487
1778
  const d = db || getDatabase();
1488
- d.run(`DELETE FROM contact_projects WHERE contact_id = ? AND project_id = ?`, [contactId, projectId]);
1779
+ setContactProjectMembershipWithoutReceipt(contactId, projectId, false, d);
1489
1780
  }
1490
1781
  function getContactProjectIds(contactId, db) {
1491
1782
  const d = db || getDatabase();
1492
- const rows = d.query(`SELECT project_id FROM contact_projects WHERE contact_id = ?`).all(contactId);
1783
+ const rows = d.query(`SELECT project_id FROM contact_projects WHERE contact_id = ? ORDER BY project_id ASC`).all(contactId);
1493
1784
  return rows.map((r) => r.project_id);
1494
1785
  }
1495
1786
  function listContactIdsByProject(projectId, db) {
1496
1787
  const d = db || getDatabase();
1497
- const rows = d.query(`SELECT contact_id FROM contact_projects WHERE project_id = ?`).all(projectId);
1788
+ const rows = d.query(`SELECT contact_id FROM contact_projects WHERE project_id = ? ORDER BY contact_id ASC`).all(projectId);
1498
1789
  return rows.map((r) => r.contact_id);
1499
1790
  }
1500
1791
  function setContactProjects(contactId, projectIds, db) {
1501
1792
  const d = db || getDatabase();
1502
- d.run(`DELETE FROM contact_projects WHERE contact_id = ?`, [contactId]);
1503
- for (const pid of projectIds) {
1504
- d.run(`INSERT OR IGNORE INTO contact_projects (contact_id, project_id) VALUES (?, ?)`, [contactId, pid]);
1505
- }
1793
+ replaceContactProjectMembershipsWithoutReceipts(contactId, projectIds, d);
1506
1794
  }
1507
1795
  var init_contacts = __esm(() => {
1508
1796
  init_types();
1509
1797
  init_database();
1510
1798
  init_activity();
1511
1799
  init_tombstones();
1800
+ init_project_memberships2();
1512
1801
  });
1513
1802
 
1514
1803
  // node_modules/ajv/dist/compile/codegen/code.js
@@ -7542,8 +7831,8 @@ var require_discriminator = __commonJS((exports) => {
7542
7831
  if (!tagRequired)
7543
7832
  throw new Error(`discriminator: "${tagName}" must be required`);
7544
7833
  return oneOfMapping;
7545
- function hasRequired({ required: required2 }) {
7546
- return Array.isArray(required2) && required2.includes(tagName);
7834
+ function hasRequired({ required: required3 }) {
7835
+ return Array.isArray(required3) && required3.includes(tagName);
7547
7836
  }
7548
7837
  function addMappings(sch, i) {
7549
7838
  if (sch.const) {
@@ -9016,6 +9305,102 @@ var init_pg_migrations = __esm(() => {
9016
9305
  CREATE INDEX IF NOT EXISTS idx_contacts_tombstones_deleted_at ON _contacts_tombstones(deleted_at);
9017
9306
 
9018
9307
  INSERT INTO _migrations (version) VALUES (12) ON CONFLICT DO NOTHING;
9308
+ `,
9309
+ `
9310
+ -- The contact_tags primary key is (contact_id, tag_id), which does not
9311
+ -- support the public tag_id filter efficiently. Keep this forward-only and
9312
+ -- idempotent for populated cloud deployments.
9313
+ CREATE INDEX IF NOT EXISTS idx_contact_tags_tag_contact ON contact_tags(tag_id, contact_id);
9314
+
9315
+ INSERT INTO _migrations (version) VALUES (13) ON CONFLICT DO NOTHING;
9316
+ `,
9317
+ `
9318
+ CREATE TABLE IF NOT EXISTS contact_project_membership_states (
9319
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
9320
+ project_id TEXT NOT NULL,
9321
+ linked BOOLEAN NOT NULL,
9322
+ revision BIGINT NOT NULL DEFAULT 0 CHECK(revision >= 0),
9323
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9324
+ PRIMARY KEY (contact_id, project_id)
9325
+ );
9326
+
9327
+ INSERT INTO contact_project_membership_states
9328
+ (contact_id, project_id, linked, revision, updated_at)
9329
+ SELECT contact_id, project_id, TRUE, 0, NOW()
9330
+ FROM contact_projects
9331
+ ON CONFLICT DO NOTHING;
9332
+
9333
+ CREATE TABLE IF NOT EXISTS contact_project_membership_receipts (
9334
+ receipt_id TEXT PRIMARY KEY,
9335
+ direction TEXT NOT NULL CHECK(direction IN ('attach', 'detach')),
9336
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
9337
+ project_id TEXT NOT NULL,
9338
+ operation_id TEXT NOT NULL,
9339
+ step_id TEXT NOT NULL,
9340
+ expected_version TEXT NOT NULL,
9341
+ before_json JSONB NOT NULL,
9342
+ after_json JSONB NOT NULL,
9343
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9344
+ UNIQUE(operation_id, step_id)
9345
+ );
9346
+
9347
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_states_project
9348
+ ON contact_project_membership_states(project_id, linked, contact_id);
9349
+ CREATE INDEX IF NOT EXISTS idx_contact_project_membership_receipts_target
9350
+ ON contact_project_membership_receipts(contact_id, project_id, created_at);
9351
+
9352
+ CREATE OR REPLACE FUNCTION sync_contact_project_membership_state_from_legacy()
9353
+ RETURNS TRIGGER
9354
+ LANGUAGE plpgsql
9355
+ AS $$
9356
+ DECLARE
9357
+ target_contact_id TEXT;
9358
+ target_project_id TEXT;
9359
+ target_linked BOOLEAN;
9360
+ BEGIN
9361
+ IF TG_OP = 'INSERT' THEN
9362
+ target_contact_id := NEW.contact_id;
9363
+ target_project_id := NEW.project_id;
9364
+ target_linked := TRUE;
9365
+ ELSE
9366
+ target_contact_id := OLD.contact_id;
9367
+ target_project_id := OLD.project_id;
9368
+ target_linked := FALSE;
9369
+ IF NOT EXISTS (SELECT 1 FROM contacts WHERE id = target_contact_id) THEN
9370
+ RETURN OLD;
9371
+ END IF;
9372
+ END IF;
9373
+
9374
+ INSERT INTO contact_project_membership_states
9375
+ (contact_id, project_id, linked, revision, updated_at)
9376
+ VALUES (target_contact_id, target_project_id, target_linked, 1, NOW())
9377
+ ON CONFLICT (contact_id, project_id) DO UPDATE SET
9378
+ linked = EXCLUDED.linked,
9379
+ revision = CASE
9380
+ WHEN contact_project_membership_states.linked IS DISTINCT FROM EXCLUDED.linked
9381
+ THEN contact_project_membership_states.revision + 1
9382
+ ELSE contact_project_membership_states.revision
9383
+ END,
9384
+ updated_at = CASE
9385
+ WHEN contact_project_membership_states.linked IS DISTINCT FROM EXCLUDED.linked
9386
+ THEN NOW()
9387
+ ELSE contact_project_membership_states.updated_at
9388
+ END;
9389
+
9390
+ IF TG_OP = 'INSERT' THEN
9391
+ RETURN NEW;
9392
+ END IF;
9393
+ RETURN OLD;
9394
+ END;
9395
+ $$;
9396
+
9397
+ DROP TRIGGER IF EXISTS sync_contact_project_membership_state_from_legacy
9398
+ ON contact_projects;
9399
+ CREATE TRIGGER sync_contact_project_membership_state_from_legacy
9400
+ AFTER INSERT OR DELETE ON contact_projects
9401
+ FOR EACH ROW EXECUTE FUNCTION sync_contact_project_membership_state_from_legacy();
9402
+
9403
+ INSERT INTO _migrations (version) VALUES (14) ON CONFLICT DO NOTHING;
9019
9404
  `
9020
9405
  ];
9021
9406
  });
@@ -10051,7 +10436,7 @@ function listImages() {
10051
10436
  init_database();
10052
10437
  import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync4, unlinkSync as unlinkSync2 } from "fs";
10053
10438
  import { join as join3 } from "path";
10054
- import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto";
10439
+ import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash as createHash2 } from "crypto";
10055
10440
  var VAULT_DIR = getDataDir();
10056
10441
  var VAULT_CONFIG = join3(VAULT_DIR, "vault.json");
10057
10442
  var VAULT_SESSION = join3(VAULT_DIR, ".vault-session");
@@ -10097,7 +10482,7 @@ function initVault(passphrase) {
10097
10482
  mkdirSync4(DOCUMENTS_DIR, { recursive: true });
10098
10483
  const salt = randomBytes(32);
10099
10484
  const key = deriveKey(passphrase, salt);
10100
- const keyHash = createHash("sha256").update(key).digest("hex");
10485
+ const keyHash = createHash2("sha256").update(key).digest("hex");
10101
10486
  const config = { salt: salt.toString("hex"), key_hash: keyHash, created_at: new Date().toISOString() };
10102
10487
  writeFileSync2(VAULT_CONFIG, JSON.stringify(config, null, 2));
10103
10488
  _derivedKey = key;
@@ -10112,7 +10497,7 @@ function unlockVault(passphrase) {
10112
10497
  const config = JSON.parse(readFileSync2(VAULT_CONFIG, "utf-8"));
10113
10498
  const salt = Buffer.from(config.salt, "hex");
10114
10499
  const key = deriveKey(passphrase, salt);
10115
- const keyHash = createHash("sha256").update(key).digest("hex");
10500
+ const keyHash = createHash2("sha256").update(key).digest("hex");
10116
10501
  if (keyHash !== config.key_hash)
10117
10502
  return false;
10118
10503
  _derivedKey = key;
@@ -10243,7 +10628,7 @@ var exports_util = {};
10243
10628
  __export(exports_util, {
10244
10629
  unwrapMessage: () => unwrapMessage,
10245
10630
  stringifyPrimitive: () => stringifyPrimitive,
10246
- required: () => required,
10631
+ required: () => required2,
10247
10632
  randomString: () => randomString,
10248
10633
  propertyKeyTypes: () => propertyKeyTypes,
10249
10634
  promiseAllObject: () => promiseAllObject,
@@ -10654,7 +11039,7 @@ function partial(Class, schema, mask) {
10654
11039
  checks: []
10655
11040
  });
10656
11041
  }
10657
- function required(Class, schema, mask) {
11042
+ function required2(Class, schema, mask) {
10658
11043
  const oldShape = schema._zod.def.shape;
10659
11044
  const shape = { ...oldShape };
10660
11045
  if (mask) {
@@ -21096,7 +21481,7 @@ function parseObjectDef(def, refs) {
21096
21481
  type: "object",
21097
21482
  properties: {}
21098
21483
  };
21099
- const required2 = [];
21484
+ const required3 = [];
21100
21485
  const shape = def.shape();
21101
21486
  for (const propName in shape) {
21102
21487
  let propDef = shape[propName];
@@ -21123,11 +21508,11 @@ function parseObjectDef(def, refs) {
21123
21508
  }
21124
21509
  result.properties[propName] = parsedDef;
21125
21510
  if (!propOptional) {
21126
- required2.push(propName);
21511
+ required3.push(propName);
21127
21512
  }
21128
21513
  }
21129
- if (required2.length) {
21130
- result.required = required2;
21514
+ if (required3.length) {
21515
+ result.required = required3;
21131
21516
  }
21132
21517
  const additionalProperties = decideAdditionalProperties(def, refs);
21133
21518
  if (additionalProperties !== undefined) {
@@ -23886,6 +24271,7 @@ function getStorageStatus(db = getDatabase()) {
23886
24271
 
23887
24272
  // src/store/index.ts
23888
24273
  init_contacts();
24274
+ init_project_memberships2();
23889
24275
 
23890
24276
  // src/db/groups.ts
23891
24277
  init_database();
@@ -26488,6 +26874,15 @@ class LocalStore {
26488
26874
  async listContactIdsByProject(projectId) {
26489
26875
  return listContactIdsByProject(projectId, this.db);
26490
26876
  }
26877
+ async readContactProjectMembership(contactId, projectId) {
26878
+ return readContactProjectMembership(contactId, projectId, this.db);
26879
+ }
26880
+ async listContactProjectMemberships(projectId, maxItems) {
26881
+ return listContactProjectMemberships(projectId, maxItems, this.db);
26882
+ }
26883
+ async mutateContactProjectMembership(direction, input) {
26884
+ return mutateContactProjectMembership(direction, input, this.db);
26885
+ }
26491
26886
  async createCompany(input) {
26492
26887
  return createCompany(input, this.db);
26493
26888
  }
@@ -27110,20 +27505,33 @@ class ApiStore {
27110
27505
  async findContactByEmailAddress(address) {
27111
27506
  return this.getContactByEmail(address);
27112
27507
  }
27113
- async linkContactToProject() {
27114
- return unavailable("linkContactToProject");
27508
+ async linkContactToProject(contactId, projectId) {
27509
+ await this.client.transport.put(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
27510
+ }
27511
+ async unlinkContactFromProject(contactId, projectId) {
27512
+ await this.del(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
27513
+ }
27514
+ async getContactProjectIds(contactId) {
27515
+ return pick2(await this.g(`/contacts/${this.enc(contactId)}/projects`), "project_ids") ?? [];
27516
+ }
27517
+ async setContactProjects(contactId, projectIds) {
27518
+ await this.client.transport.put(`/contacts/${this.enc(contactId)}/projects`, { project_ids: projectIds });
27115
27519
  }
27116
- async unlinkContactFromProject() {
27117
- return unavailable("unlinkContactFromProject");
27520
+ async listContactIdsByProject(projectId) {
27521
+ return pick2(await this.g(`/projects/${this.enc(projectId)}/contacts`), "contact_ids") ?? [];
27118
27522
  }
27119
- async getContactProjectIds() {
27120
- return unavailable("getContactProjectIds");
27523
+ async readContactProjectMembership(contactId, projectId) {
27524
+ return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships/${this.enc(contactId)}`);
27121
27525
  }
27122
- async setContactProjects() {
27123
- return unavailable("setContactProjects");
27526
+ async listContactProjectMemberships(projectId, maxItems) {
27527
+ return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships`, { query: { max_items: maxItems } });
27124
27528
  }
27125
- async listContactIdsByProject() {
27126
- return unavailable("listContactIdsByProject");
27529
+ async mutateContactProjectMembership(direction, input) {
27530
+ return this.client.transport.post(`/projects/${this.enc(input.project_id)}/contact-memberships/${this.enc(input.contact_id)}/${direction}`, {
27531
+ operation_id: input.operation_id,
27532
+ step_id: input.step_id,
27533
+ expected_version: input.expected_version
27534
+ });
27127
27535
  }
27128
27536
  async createCompany(input) {
27129
27537
  const res = await this.client.create("companies", stripUndefined(input));
@@ -29016,8 +29424,8 @@ var _contactsAgents = new Map;
29016
29424
  var advancedHandlers = {
29017
29425
  get_field_history: async (a) => json3({ history: await getStore().getFieldHistory(a.contact_id, a.field_name) }),
29018
29426
  get_contact_at: async (a) => {
29019
- const snapshot = await getStore().getContactAt(a.contact_id, a.timestamp);
29020
- return json3({ contact_id: a.contact_id, timestamp: a.timestamp, snapshot });
29427
+ const snapshot2 = await getStore().getContactAt(a.contact_id, a.timestamp);
29428
+ return json3({ contact_id: a.contact_id, timestamp: a.timestamp, snapshot: snapshot2 });
29021
29429
  },
29022
29430
  get_job_history: async (a) => json3({ history: await getStore().getJobHistory(a.contact_id) }),
29023
29431
  add_job_entry: async (a) => json3(await getStore().addJobEntry(a.contact_id, {
@@ -29493,11 +29901,11 @@ function jsonSchemaToZodType(schema) {
29493
29901
  }
29494
29902
  function jsonSchemaToZodObject(schema) {
29495
29903
  const objectSchema = schema && typeof schema === "object" ? schema : {};
29496
- const required2 = new Set(objectSchema.required ?? []);
29904
+ const required3 = new Set(objectSchema.required ?? []);
29497
29905
  const shape = {};
29498
29906
  for (const [key, value] of Object.entries(objectSchema.properties ?? {})) {
29499
29907
  const propertySchema = jsonSchemaToZodType(value);
29500
- shape[key] = required2.has(key) ? propertySchema : propertySchema.optional();
29908
+ shape[key] = required3.has(key) ? propertySchema : propertySchema.optional();
29501
29909
  }
29502
29910
  return describeSchema(exports_external.object(shape).passthrough(), objectSchema.description);
29503
29911
  }
@@ -31177,11 +31585,11 @@ function isLoopbackBindHost(hostname2) {
31177
31585
  const normalized = hostname2.trim().toLowerCase();
31178
31586
  return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(normalized);
31179
31587
  }
31180
- function hasScope(principal, required2) {
31181
- const prefix = required2.split(":")[0];
31182
- return principal.scopes.has("*") || principal.scopes.has(required2) || principal.scopes.has(`${prefix}:*`);
31588
+ function hasScope(principal, required3) {
31589
+ const prefix = required3.split(":")[0];
31590
+ return principal.scopes.has("*") || principal.scopes.has(required3) || principal.scopes.has(`${prefix}:*`);
31183
31591
  }
31184
- function authenticateContactsRequest(req, required2, context = { allowUnauthenticatedLoopback: false }) {
31592
+ function authenticateContactsRequest(req, required3, context = { allowUnauthenticatedLoopback: false }) {
31185
31593
  const configured = parseTokenRecords();
31186
31594
  const token = bearerToken(req) ?? req.headers.get("x-contacts-token");
31187
31595
  if (token) {
@@ -31189,8 +31597,8 @@ function authenticateContactsRequest(req, required2, context = { allowUnauthenti
31189
31597
  if (!matched)
31190
31598
  return { ok: false, status: 401, message: "Invalid contacts token" };
31191
31599
  const principal = { id: "api-token", scopes: matched.scopes, localDevelopment: false };
31192
- if (!hasScope(principal, required2))
31193
- return { ok: false, status: 403, message: `Missing scope: ${required2}` };
31600
+ if (!hasScope(principal, required3))
31601
+ return { ok: false, status: 403, message: `Missing scope: ${required3}` };
31194
31602
  return { ok: true, principal };
31195
31603
  }
31196
31604
  if (configured.length === 0 && context.allowUnauthenticatedLoopback) {
@@ -31241,9 +31649,12 @@ function redactContactForExport(contact) {
31241
31649
  }
31242
31650
 
31243
31651
  // src/server/v1.ts
31652
+ init_project_memberships();
31244
31653
  init_cloud();
31245
31654
 
31246
31655
  // src/server/pg-store.ts
31656
+ init_project_memberships();
31657
+ import { createHash as createHash3 } from "crypto";
31247
31658
  function uuid3() {
31248
31659
  return crypto.randomUUID();
31249
31660
  }
@@ -31278,6 +31689,45 @@ function newUuid() {
31278
31689
  function nowIso() {
31279
31690
  return new Date().toISOString();
31280
31691
  }
31692
+ function contactProjectDigest(value) {
31693
+ return createHash3("sha256").update(value).digest("hex");
31694
+ }
31695
+ function contactProjectMembershipVersion(row) {
31696
+ return `cpmv_${contactProjectDigest(JSON.stringify([
31697
+ row.contact_id,
31698
+ row.project_id,
31699
+ Boolean(row.linked),
31700
+ Number(row.revision)
31701
+ ])).slice(0, 32)}`;
31702
+ }
31703
+ function contactProjectMembershipSnapshot(row) {
31704
+ return {
31705
+ contact_id: row.contact_id,
31706
+ project_id: row.project_id,
31707
+ linked: Boolean(row.linked),
31708
+ version: contactProjectMembershipVersion(row)
31709
+ };
31710
+ }
31711
+ function parseMembershipSnapshot(value) {
31712
+ if (typeof value === "string")
31713
+ return JSON.parse(value);
31714
+ return value;
31715
+ }
31716
+ function requiredMembershipValue(value, name) {
31717
+ const normalized = value.trim();
31718
+ if (!normalized)
31719
+ throw new Error(`${name} is required`);
31720
+ return normalized;
31721
+ }
31722
+ function normalizeMembershipMutationInput(input) {
31723
+ return {
31724
+ contact_id: requiredMembershipValue(input.contact_id, "contact_id"),
31725
+ project_id: requiredMembershipValue(input.project_id, "project_id"),
31726
+ operation_id: requiredMembershipValue(input.operation_id, "operation_id"),
31727
+ step_id: requiredMembershipValue(input.step_id, "step_id"),
31728
+ expected_version: requiredMembershipValue(input.expected_version, "expected_version")
31729
+ };
31730
+ }
31281
31731
  function parseJson(value) {
31282
31732
  if (!value)
31283
31733
  return {};
@@ -31345,12 +31795,87 @@ function mapTag(row) {
31345
31795
  created_at: iso(row.created_at)
31346
31796
  };
31347
31797
  }
31798
+ function mapEmail(row) {
31799
+ return {
31800
+ id: String(row["id"]),
31801
+ contact_id: row["contact_id"] ?? null,
31802
+ company_id: row["company_id"] ?? null,
31803
+ address: String(row["address"]),
31804
+ type: row["type"],
31805
+ is_primary: Boolean(row["is_primary"]),
31806
+ created_at: iso(row["created_at"])
31807
+ };
31808
+ }
31809
+ function mapPhone(row) {
31810
+ return {
31811
+ id: String(row["id"]),
31812
+ contact_id: row["contact_id"] ?? null,
31813
+ company_id: row["company_id"] ?? null,
31814
+ number: String(row["number"]),
31815
+ country_code: row["country_code"] ?? null,
31816
+ type: row["type"],
31817
+ is_primary: Boolean(row["is_primary"]),
31818
+ created_at: iso(row["created_at"])
31819
+ };
31820
+ }
31348
31821
 
31349
31822
  class ContactsPgStore {
31350
31823
  client;
31351
31824
  constructor(client) {
31352
31825
  this.client = client;
31353
31826
  }
31827
+ async attachTags(contacts) {
31828
+ const tagsByContactId = new Map(contacts.map((contact) => [contact.id, []]));
31829
+ if (contacts.length === 0)
31830
+ return [];
31831
+ const rows = await this.client.many(`SELECT ct.contact_id, t.*
31832
+ FROM contact_tags ct
31833
+ JOIN tags t ON t.id = ct.tag_id
31834
+ WHERE ct.contact_id = ANY($1::text[])
31835
+ ORDER BY t.name ASC`, [contacts.map((contact) => contact.id)]);
31836
+ for (const row of rows)
31837
+ tagsByContactId.get(row.contact_id)?.push(mapTag(row));
31838
+ return contacts.map((contact) => ({ ...contact, tags: tagsByContactId.get(contact.id) ?? [] }));
31839
+ }
31840
+ async attachContactMethods(contacts) {
31841
+ if (contacts.length === 0)
31842
+ return [];
31843
+ const ids = contacts.map((contact) => contact.id);
31844
+ const emailsByContactId = new Map(ids.map((id) => [id, []]));
31845
+ const phonesByContactId = new Map(ids.map((id) => [id, []]));
31846
+ const [emailRows, phoneRows] = await Promise.all([
31847
+ this.client.many(`SELECT * FROM emails WHERE contact_id = ANY($1::text[]) ORDER BY created_at ASC`, [ids]),
31848
+ this.client.many(`SELECT * FROM phones WHERE contact_id = ANY($1::text[]) ORDER BY created_at ASC`, [ids])
31849
+ ]);
31850
+ for (const row of emailRows)
31851
+ emailsByContactId.get(String(row["contact_id"]))?.push(mapEmail(row));
31852
+ for (const row of phoneRows)
31853
+ phonesByContactId.get(String(row["contact_id"]))?.push(mapPhone(row));
31854
+ return contacts.map((contact) => ({
31855
+ ...contact,
31856
+ emails: emailsByContactId.get(contact.id) ?? [],
31857
+ phones: phonesByContactId.get(contact.id) ?? []
31858
+ }));
31859
+ }
31860
+ async attachContactDetails(contacts) {
31861
+ return this.attachContactMethods(await this.attachTags(contacts));
31862
+ }
31863
+ async insertContactMethods(contactId, emails, phones) {
31864
+ for (const email2 of emails ?? []) {
31865
+ await this.client.query(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary)
31866
+ SELECT $1, $2, NULL, $3, $4, $5
31867
+ WHERE NOT EXISTS (
31868
+ SELECT 1 FROM emails WHERE contact_id = $2 AND LOWER(address) = LOWER($3)
31869
+ )`, [uuid3(), contactId, email2.address, email2.type ?? "work", email2.is_primary ?? false]);
31870
+ }
31871
+ for (const phone of phones ?? []) {
31872
+ await this.client.query(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary)
31873
+ SELECT $1, $2, NULL, $3, $4, $5, $6
31874
+ WHERE NOT EXISTS (
31875
+ SELECT 1 FROM phones WHERE contact_id = $2 AND number = $3
31876
+ )`, [uuid3(), contactId, phone.number, phone.country_code ?? null, phone.type ?? "mobile", phone.is_primary ?? false]);
31877
+ }
31878
+ }
31354
31879
  async listContacts(filter = {}) {
31355
31880
  const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
31356
31881
  const offset = Math.max(filter.offset ?? 0, 0);
@@ -31364,6 +31889,10 @@ class ContactsPgStore {
31364
31889
  params.push(filter.status);
31365
31890
  where.push(`status = $${params.length}`);
31366
31891
  }
31892
+ if (filter.tag_id) {
31893
+ params.push(filter.tag_id);
31894
+ where.push(`EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = contacts.id AND ct.tag_id = $${params.length})`);
31895
+ }
31367
31896
  if (filter.q) {
31368
31897
  params.push(filter.q);
31369
31898
  where.push(`search_vector @@ plainto_tsquery('simple', $${params.length})`);
@@ -31372,11 +31901,13 @@ class ContactsPgStore {
31372
31901
  const countRow = await this.client.get(`SELECT COUNT(*)::text AS count FROM contacts ${whereSql}`, params);
31373
31902
  params.push(limit, offset);
31374
31903
  const rows = await this.client.many(`SELECT * FROM contacts ${whereSql} ORDER BY display_name ASC LIMIT $${params.length - 1} OFFSET $${params.length}`, params);
31375
- return { contacts: rows.map(mapContact), count: Number(countRow?.count ?? rows.length) };
31904
+ return { contacts: await this.attachContactDetails(rows.map(mapContact)), count: Number(countRow?.count ?? rows.length) };
31376
31905
  }
31377
31906
  async getContact(id) {
31378
31907
  const row = await this.client.get(`SELECT * FROM contacts WHERE id = $1`, [id]);
31379
- return row ? mapContact(row) : null;
31908
+ if (!row)
31909
+ return null;
31910
+ return (await this.attachContactDetails([mapContact(row)]))[0];
31380
31911
  }
31381
31912
  async createContact(input) {
31382
31913
  const id = uuid3();
@@ -31412,7 +31943,8 @@ class ContactsPgStore {
31412
31943
  input.priority ?? 3,
31413
31944
  input.timezone ?? null
31414
31945
  ]);
31415
- return mapContact(row);
31946
+ await this.insertContactMethods(id, input.emails, input.phones);
31947
+ return (await this.attachContactDetails([mapContact(row)]))[0];
31416
31948
  }
31417
31949
  async updateContact(id, input) {
31418
31950
  const allowed = {};
@@ -31447,17 +31979,192 @@ class ContactsPgStore {
31447
31979
  allowed.custom_fields = JSON.stringify(input.custom_fields);
31448
31980
  }
31449
31981
  const keys = Object.keys(allowed);
31450
- if (keys.length === 0)
31982
+ const hasMethodAppends = Boolean(input.emails_add?.length || input.phones_add?.length);
31983
+ if (keys.length === 0 && !hasMethodAppends)
31451
31984
  return this.getContact(id);
31452
31985
  const sets = keys.map((k, i) => `${k} = $${i + 2}`);
31453
31986
  sets.push(`updated_at = NOW()`);
31454
31987
  const row = await this.client.get(`UPDATE contacts SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, [id, ...keys.map((k) => allowed[k])]);
31455
- return row ? mapContact(row) : null;
31988
+ if (!row)
31989
+ return null;
31990
+ await this.insertContactMethods(id, input.emails_add, input.phones_add);
31991
+ return (await this.attachContactDetails([mapContact(row)]))[0];
31456
31992
  }
31457
31993
  async deleteContact(id) {
31458
31994
  const result = await this.client.query(`DELETE FROM contacts WHERE id = $1`, [id]);
31459
31995
  return result.rowCount > 0;
31460
31996
  }
31997
+ async linkContactToProject(contactId, projectId) {
31998
+ await this.client.transaction(async (client) => {
31999
+ await this.transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, true);
32000
+ });
32001
+ }
32002
+ async unlinkContactFromProject(contactId, projectId) {
32003
+ return this.client.transaction((client) => this.transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, false));
32004
+ }
32005
+ async getContactProjectIds(contactId) {
32006
+ const rows = await this.client.many(`SELECT project_id
32007
+ FROM contact_projects
32008
+ WHERE contact_id = $1
32009
+ ORDER BY project_id ASC`, [contactId]);
32010
+ return rows.map((row) => row.project_id);
32011
+ }
32012
+ async setContactProjects(contactId, projectIds) {
32013
+ const uniqueProjectIds = [...new Set(projectIds.map((projectId) => requiredMembershipValue(projectId, "project_id")))];
32014
+ await this.client.transaction(async (client) => {
32015
+ const current = await client.many(`SELECT project_id FROM contact_projects WHERE contact_id = $1`, [contactId]);
32016
+ const desired = new Set(uniqueProjectIds);
32017
+ const population = new Set([...current.map((row) => row.project_id), ...uniqueProjectIds]);
32018
+ for (const projectId of population) {
32019
+ await this.transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, desired.has(projectId));
32020
+ }
32021
+ });
32022
+ return uniqueProjectIds;
32023
+ }
32024
+ async listContactIdsByProject(projectId) {
32025
+ const rows = await this.client.many(`SELECT contact_id
32026
+ FROM contact_projects
32027
+ WHERE project_id = $1
32028
+ ORDER BY contact_id ASC`, [projectId]);
32029
+ return rows.map((row) => row.contact_id);
32030
+ }
32031
+ async contactProjectMembershipState(client, contactId, projectId, forUpdate = false) {
32032
+ let row = await client.get(`SELECT contact_id, project_id, linked, revision
32033
+ FROM contact_project_membership_states
32034
+ WHERE contact_id = $1 AND project_id = $2${forUpdate ? " FOR UPDATE" : ""}`, [contactId, projectId]);
32035
+ if (row)
32036
+ return row;
32037
+ const linked = Boolean(await client.get(`SELECT 1 AS present FROM contact_projects WHERE contact_id = $1 AND project_id = $2`, [contactId, projectId]));
32038
+ if (!forUpdate) {
32039
+ return { contact_id: contactId, project_id: projectId, linked, revision: 0 };
32040
+ }
32041
+ await client.execute(`INSERT INTO contact_project_membership_states
32042
+ (contact_id, project_id, linked, revision, updated_at)
32043
+ VALUES ($1, $2, $3, 0, NOW())
32044
+ ON CONFLICT (contact_id, project_id) DO NOTHING`, [contactId, projectId, linked]);
32045
+ row = await client.get(`SELECT contact_id, project_id, linked, revision
32046
+ FROM contact_project_membership_states
32047
+ WHERE contact_id = $1 AND project_id = $2
32048
+ FOR UPDATE`, [contactId, projectId]);
32049
+ if (!row)
32050
+ throw new Error("failed to initialize contact project membership state");
32051
+ return row;
32052
+ }
32053
+ async transitionContactProjectMembershipWithoutReceipt(client, contactId, projectId, linked) {
32054
+ const normalizedContactId = requiredMembershipValue(contactId, "contact_id");
32055
+ const normalizedProjectId = requiredMembershipValue(projectId, "project_id");
32056
+ const before = await this.contactProjectMembershipState(client, normalizedContactId, normalizedProjectId, true);
32057
+ const changed = Boolean(before.linked) !== linked;
32058
+ const revision = Number(before.revision) + (changed ? 1 : 0);
32059
+ await client.execute(`UPDATE contact_project_membership_states
32060
+ SET linked = $3, revision = $4, updated_at = NOW()
32061
+ WHERE contact_id = $1 AND project_id = $2`, [normalizedContactId, normalizedProjectId, linked, revision]);
32062
+ if (linked) {
32063
+ await client.execute(`INSERT INTO contact_projects (contact_id, project_id) VALUES ($1, $2)
32064
+ ON CONFLICT (contact_id, project_id) DO NOTHING`, [normalizedContactId, normalizedProjectId]);
32065
+ } else {
32066
+ await client.execute(`DELETE FROM contact_projects WHERE contact_id = $1 AND project_id = $2`, [normalizedContactId, normalizedProjectId]);
32067
+ }
32068
+ return changed;
32069
+ }
32070
+ async readContactProjectMembership(contactId, projectId) {
32071
+ return contactProjectMembershipSnapshot(await this.contactProjectMembershipState(this.client, contactId, projectId));
32072
+ }
32073
+ async listContactProjectMemberships(projectId, maxItems) {
32074
+ if (!Number.isInteger(maxItems) || maxItems < 1)
32075
+ throw new Error("max_items must be a positive integer");
32076
+ const rows = await this.client.many(`SELECT cp.contact_id, cp.project_id,
32077
+ COALESCE(state.linked, TRUE) AS linked,
32078
+ COALESCE(state.revision, 0) AS revision
32079
+ FROM contact_projects cp
32080
+ LEFT JOIN contact_project_membership_states state
32081
+ ON state.contact_id = cp.contact_id AND state.project_id = cp.project_id
32082
+ WHERE cp.project_id = $1
32083
+ ORDER BY cp.contact_id ASC
32084
+ LIMIT $2`, [projectId, maxItems + 1]);
32085
+ if (rows.length > maxItems) {
32086
+ throw new Error(`contact project membership collection exceeds max_items=${maxItems}`);
32087
+ }
32088
+ return {
32089
+ project_id: projectId,
32090
+ contact_ids: rows.map((row) => row.contact_id),
32091
+ complete: true,
32092
+ membership_revision: `cpml_${contactProjectDigest(JSON.stringify(rows.map(contactProjectMembershipSnapshot))).slice(0, 32)}`
32093
+ };
32094
+ }
32095
+ async mutateContactProjectMembership(direction, rawInput) {
32096
+ const input = normalizeMembershipMutationInput(rawInput);
32097
+ return this.client.transaction(async (client) => {
32098
+ await client.execute(`SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, [input.operation_id, input.step_id]);
32099
+ const receipt = await client.get(`SELECT direction, contact_id, project_id, operation_id, step_id, expected_version,
32100
+ before_json, after_json, receipt_id
32101
+ FROM contact_project_membership_receipts
32102
+ WHERE operation_id = $1 AND step_id = $2`, [input.operation_id, input.step_id]);
32103
+ if (receipt) {
32104
+ if (receipt.direction !== direction || receipt.contact_id !== input.contact_id || receipt.project_id !== input.project_id || receipt.expected_version !== input.expected_version) {
32105
+ throw new ContactProjectMembershipConflictError("operation_id/step_id already accepted for a different contact-project membership mutation");
32106
+ }
32107
+ return {
32108
+ outcome: "duplicate_of_accepted",
32109
+ operation_id: receipt.operation_id,
32110
+ step_id: receipt.step_id,
32111
+ before: parseMembershipSnapshot(receipt.before_json),
32112
+ after: parseMembershipSnapshot(receipt.after_json),
32113
+ receipt_id: receipt.receipt_id
32114
+ };
32115
+ }
32116
+ const contact = await client.get(`SELECT id FROM contacts WHERE id = $1`, [input.contact_id]);
32117
+ if (!contact)
32118
+ throw new Error(`contact not found: ${input.contact_id}`);
32119
+ const beforeRow = await this.contactProjectMembershipState(client, input.contact_id, input.project_id, true);
32120
+ const before = contactProjectMembershipSnapshot(beforeRow);
32121
+ if (before.version !== input.expected_version) {
32122
+ throw new ContactProjectMembershipConflictError(`contact project membership expected_version conflict: expected ${input.expected_version}, current ${before.version}`);
32123
+ }
32124
+ const desiredLinked = direction === "attach";
32125
+ const changed = before.linked !== desiredLinked;
32126
+ const revision = Number(beforeRow.revision) + (changed ? 1 : 0);
32127
+ const afterRow = { ...beforeRow, linked: desiredLinked, revision };
32128
+ await client.execute(`UPDATE contact_project_membership_states
32129
+ SET linked = $3, revision = $4, updated_at = NOW()
32130
+ WHERE contact_id = $1 AND project_id = $2`, [input.contact_id, input.project_id, desiredLinked, revision]);
32131
+ if (desiredLinked) {
32132
+ await client.execute(`INSERT INTO contact_projects (contact_id, project_id) VALUES ($1, $2)
32133
+ ON CONFLICT (contact_id, project_id) DO NOTHING`, [input.contact_id, input.project_id]);
32134
+ } else {
32135
+ await client.execute(`DELETE FROM contact_projects WHERE contact_id = $1 AND project_id = $2`, [input.contact_id, input.project_id]);
32136
+ }
32137
+ const after = contactProjectMembershipSnapshot(afterRow);
32138
+ const id = `cpmr_${contactProjectDigest(JSON.stringify([
32139
+ input.operation_id,
32140
+ input.step_id,
32141
+ input.contact_id,
32142
+ input.project_id
32143
+ ])).slice(0, 32)}`;
32144
+ await client.execute(`INSERT INTO contact_project_membership_receipts (
32145
+ receipt_id, direction, contact_id, project_id, operation_id, step_id,
32146
+ expected_version, before_json, after_json
32147
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb)`, [
32148
+ id,
32149
+ direction,
32150
+ input.contact_id,
32151
+ input.project_id,
32152
+ input.operation_id,
32153
+ input.step_id,
32154
+ input.expected_version,
32155
+ JSON.stringify(before),
32156
+ JSON.stringify(after)
32157
+ ]);
32158
+ return {
32159
+ outcome: changed ? "accepted" : "duplicate_of_accepted",
32160
+ operation_id: input.operation_id,
32161
+ step_id: input.step_id,
32162
+ before,
32163
+ after,
32164
+ receipt_id: id
32165
+ };
32166
+ });
32167
+ }
31461
32168
  async listCompanies(filter = {}) {
31462
32169
  const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
31463
32170
  const offset = Math.max(filter.offset ?? 0, 0);
@@ -32914,6 +33621,94 @@ async function readJson(req) {
32914
33621
  return null;
32915
33622
  }
32916
33623
  }
33624
+ function contactListFilterFromUrl(url) {
33625
+ return {
33626
+ ...url.searchParams.get("company_id") ? { company_id: url.searchParams.get("company_id") } : {},
33627
+ ...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
33628
+ ...url.searchParams.get("tag_id") ? { tag_id: url.searchParams.get("tag_id") } : {},
33629
+ ...url.searchParams.get("q") ? { q: url.searchParams.get("q") } : {},
33630
+ ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
33631
+ ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
33632
+ };
33633
+ }
33634
+ async function handleContactProjectsRoute(req, method, segments, store) {
33635
+ const resource = segments[1];
33636
+ const id = segments[2];
33637
+ const sub = segments[3];
33638
+ if (resource === "contacts" && id && sub === "projects") {
33639
+ const contact = await store.getContact(id);
33640
+ if (!contact)
33641
+ return error2(404, "contact not found");
33642
+ const projectId = segments[4];
33643
+ if (projectId) {
33644
+ if (method === "PUT") {
33645
+ await store.linkContactToProject(id, projectId);
33646
+ return json5({ attached: true, contact_id: id, project_id: projectId });
33647
+ }
33648
+ if (method === "DELETE") {
33649
+ const removed = await store.unlinkContactFromProject(id, projectId);
33650
+ return json5({ removed, contact_id: id, project_id: projectId });
33651
+ }
33652
+ return error2(405, `method ${method} not allowed on /v1/contacts/:contact_id/projects/:project_id`);
33653
+ }
33654
+ if (method === "GET") {
33655
+ return json5({ contact_id: id, project_ids: await store.getContactProjectIds(id) });
33656
+ }
33657
+ if (method === "PUT") {
33658
+ const body = await readJson(req);
33659
+ if (!body || !Array.isArray(body.project_ids) || !body.project_ids.every((value) => typeof value === "string" && value.trim().length > 0)) {
33660
+ return error2(400, "project_ids must be an array of non-empty strings");
33661
+ }
33662
+ const projectIds = await store.setContactProjects(id, body.project_ids);
33663
+ return json5({ contact_id: id, project_ids: projectIds });
33664
+ }
33665
+ return error2(405, `method ${method} not allowed on /v1/contacts/:contact_id/projects`);
33666
+ }
33667
+ if (resource === "projects" && id && sub === "contacts") {
33668
+ if (method === "GET") {
33669
+ return json5({ project_id: id, contact_ids: await store.listContactIdsByProject(id) });
33670
+ }
33671
+ return error2(405, `method ${method} not allowed on /v1/projects/:project_id/contacts`);
33672
+ }
33673
+ if (resource === "projects" && id && sub === "contact-memberships") {
33674
+ const contactId = segments[4];
33675
+ const action = segments[5];
33676
+ if (!contactId) {
33677
+ if (method !== "GET") {
33678
+ return error2(405, `method ${method} not allowed on /v1/projects/:project_id/contact-memberships`);
33679
+ }
33680
+ const rawMaxItems = new URL(req.url).searchParams.get("max_items");
33681
+ const maxItems = rawMaxItems === null ? 1000 : Number(rawMaxItems);
33682
+ if (!Number.isInteger(maxItems) || maxItems < 1)
33683
+ return error2(400, "max_items must be a positive integer");
33684
+ return json5(await store.listContactProjectMemberships(id, maxItems));
33685
+ }
33686
+ if (!action) {
33687
+ if (method !== "GET") {
33688
+ return error2(405, `method ${method} not allowed on /v1/projects/:project_id/contact-memberships/:contact_id`);
33689
+ }
33690
+ const contact = await store.getContact(contactId);
33691
+ if (!contact)
33692
+ return error2(404, "contact not found");
33693
+ return json5(await store.readContactProjectMembership(contactId, id));
33694
+ }
33695
+ if ((action === "attach" || action === "detach") && method === "POST") {
33696
+ const body = await readJson(req);
33697
+ 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) {
33698
+ return error2(400, "operation_id, step_id, and expected_version are required non-empty strings");
33699
+ }
33700
+ return json5(await store.mutateContactProjectMembership(action, {
33701
+ contact_id: contactId,
33702
+ project_id: id,
33703
+ operation_id: body.operation_id,
33704
+ step_id: body.step_id,
33705
+ expected_version: body.expected_version
33706
+ }));
33707
+ }
33708
+ return error2(405, `method ${method} not allowed on contact-project membership route`);
33709
+ }
33710
+ return null;
33711
+ }
32917
33712
  async function handleV1Request(req, url) {
32918
33713
  const path = url.pathname;
32919
33714
  if (path !== "/v1" && !path.startsWith("/v1/"))
@@ -32948,6 +33743,9 @@ async function handleV1Request(req, url) {
32948
33743
  return v === null ? undefined : Number(v);
32949
33744
  };
32950
33745
  try {
33746
+ const projectLinks = await handleContactProjectsRoute(req, method, segments, store);
33747
+ if (projectLinks)
33748
+ return projectLinks;
32951
33749
  if (resource === "contacts" && id && sub) {
32952
33750
  if (sub === "tags") {
32953
33751
  const tagId = segments[4];
@@ -33027,13 +33825,7 @@ async function handleV1Request(req, url) {
33027
33825
  if (resource === "contacts") {
33028
33826
  if (!id) {
33029
33827
  if (method === "GET") {
33030
- const result = await store.listContacts({
33031
- ...url.searchParams.get("company_id") ? { company_id: url.searchParams.get("company_id") } : {},
33032
- ...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
33033
- ...url.searchParams.get("q") ? { q: url.searchParams.get("q") } : {},
33034
- ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
33035
- ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
33036
- });
33828
+ const result = await store.listContacts(contactListFilterFromUrl(url));
33037
33829
  return json5(result);
33038
33830
  }
33039
33831
  if (method === "POST") {
@@ -33655,6 +34447,8 @@ async function handleV1Request(req, url) {
33655
34447
  return error2(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
33656
34448
  } catch (e) {
33657
34449
  const msg = e.message || "internal error";
34450
+ if (e instanceof ContactProjectMembershipConflictError)
34451
+ return error2(409, msg);
33658
34452
  if (/violates|constraint|invalid input|duplicate key/i.test(msg))
33659
34453
  return error2(400, msg);
33660
34454
  return error2(500, msg);
@@ -33693,6 +34487,7 @@ function getPackageVersion() {
33693
34487
  // src/server/openapi.ts
33694
34488
  var contactSchema = {
33695
34489
  type: "object",
34490
+ required: ["tags"],
33696
34491
  properties: {
33697
34492
  id: { type: "string" },
33698
34493
  first_name: { type: "string" },
@@ -33707,6 +34502,7 @@ var contactSchema = {
33707
34502
  sensitivity: { type: "string" },
33708
34503
  archived: { type: "boolean" },
33709
34504
  priority: { type: "number" },
34505
+ tags: { type: "array", items: { $ref: "#/components/schemas/Tag" } },
33710
34506
  created_at: { type: "string" },
33711
34507
  updated_at: { type: "string" }
33712
34508
  }
@@ -33826,6 +34622,58 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
33826
34622
  color: { type: "string" },
33827
34623
  description: { type: "string" }
33828
34624
  }
34625
+ },
34626
+ ProjectIdsInput: {
34627
+ type: "object",
34628
+ required: ["project_ids"],
34629
+ properties: {
34630
+ project_ids: {
34631
+ type: "array",
34632
+ items: { type: "string", minLength: 1 },
34633
+ uniqueItems: true
34634
+ }
34635
+ }
34636
+ },
34637
+ ContactProjectMembershipSnapshot: {
34638
+ type: "object",
34639
+ required: ["contact_id", "project_id", "linked", "version"],
34640
+ properties: {
34641
+ contact_id: { type: "string" },
34642
+ project_id: { type: "string" },
34643
+ linked: { type: "boolean" },
34644
+ version: { type: "string" }
34645
+ }
34646
+ },
34647
+ ContactProjectMembershipMutationInput: {
34648
+ type: "object",
34649
+ required: ["operation_id", "step_id", "expected_version"],
34650
+ properties: {
34651
+ operation_id: { type: "string", minLength: 1 },
34652
+ step_id: { type: "string", minLength: 1 },
34653
+ expected_version: { type: "string", minLength: 1 }
34654
+ }
34655
+ },
34656
+ ContactProjectMembershipMutationResult: {
34657
+ type: "object",
34658
+ required: ["outcome", "operation_id", "step_id", "before", "after", "receipt_id"],
34659
+ properties: {
34660
+ outcome: { type: "string", enum: ["accepted", "duplicate_of_accepted"] },
34661
+ operation_id: { type: "string" },
34662
+ step_id: { type: "string" },
34663
+ before: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" },
34664
+ after: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" },
34665
+ receipt_id: { type: "string" }
34666
+ }
34667
+ },
34668
+ ContactProjectMembershipListResult: {
34669
+ type: "object",
34670
+ required: ["project_id", "contact_ids", "complete", "membership_revision"],
34671
+ properties: {
34672
+ project_id: { type: "string" },
34673
+ contact_ids: { type: "array", items: { type: "string" } },
34674
+ complete: { type: "boolean", const: true },
34675
+ membership_revision: { type: "string" }
34676
+ }
33829
34677
  }
33830
34678
  }
33831
34679
  },
@@ -33839,6 +34687,7 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
33839
34687
  { name: "q", in: "query", schema: { type: "string" } },
33840
34688
  { name: "company_id", in: "query", schema: { type: "string" } },
33841
34689
  { name: "status", in: "query", schema: { type: "string" } },
34690
+ { name: "tag_id", in: "query", schema: { type: "string" } },
33842
34691
  { name: "limit", in: "query", schema: { type: "number" } },
33843
34692
  { name: "offset", in: "query", schema: { type: "number" } }
33844
34693
  ],
@@ -34025,6 +34874,167 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
34025
34874
  })
34026
34875
  }
34027
34876
  },
34877
+ "/v1/contacts/{contact_id}/projects": {
34878
+ get: {
34879
+ operationId: "getContactProjectIds",
34880
+ summary: "List project ids attached to a contact",
34881
+ parameters: [
34882
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
34883
+ ],
34884
+ responses: objResponse({
34885
+ contact_id: { type: "string" },
34886
+ project_ids: { type: "array", items: { type: "string" } }
34887
+ })
34888
+ },
34889
+ put: {
34890
+ operationId: "setContactProjects",
34891
+ summary: "Atomically replace a contact's project memberships",
34892
+ parameters: [
34893
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
34894
+ ],
34895
+ requestBody: {
34896
+ required: true,
34897
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ProjectIdsInput" } } }
34898
+ },
34899
+ responses: objResponse({
34900
+ contact_id: { type: "string" },
34901
+ project_ids: { type: "array", items: { type: "string" } }
34902
+ })
34903
+ }
34904
+ },
34905
+ "/v1/contacts/{contact_id}/projects/{project_id}": {
34906
+ put: {
34907
+ operationId: "linkContactToProject",
34908
+ summary: "Attach a contact to a project idempotently",
34909
+ parameters: [
34910
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } },
34911
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
34912
+ ],
34913
+ responses: objResponse({
34914
+ attached: { type: "boolean" },
34915
+ contact_id: { type: "string" },
34916
+ project_id: { type: "string" }
34917
+ })
34918
+ },
34919
+ delete: {
34920
+ operationId: "unlinkContactFromProject",
34921
+ summary: "Detach a contact from a project",
34922
+ parameters: [
34923
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } },
34924
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
34925
+ ],
34926
+ responses: objResponse({
34927
+ removed: { type: "boolean" },
34928
+ contact_id: { type: "string" },
34929
+ project_id: { type: "string" }
34930
+ })
34931
+ }
34932
+ },
34933
+ "/v1/projects/{project_id}/contacts": {
34934
+ get: {
34935
+ operationId: "listContactIdsByProject",
34936
+ summary: "List contact ids attached to a project",
34937
+ parameters: [
34938
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } }
34939
+ ],
34940
+ responses: objResponse({
34941
+ project_id: { type: "string" },
34942
+ contact_ids: { type: "array", items: { type: "string" } }
34943
+ })
34944
+ }
34945
+ },
34946
+ "/v1/projects/{project_id}/contact-memberships": {
34947
+ get: {
34948
+ operationId: "listContactProjectMemberships",
34949
+ summary: "List the complete authoritative contact membership collection for a project",
34950
+ parameters: [
34951
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
34952
+ { name: "max_items", in: "query", required: true, schema: { type: "integer", minimum: 1 } }
34953
+ ],
34954
+ responses: {
34955
+ "200": {
34956
+ content: {
34957
+ "application/json": {
34958
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipListResult" }
34959
+ }
34960
+ }
34961
+ }
34962
+ }
34963
+ }
34964
+ },
34965
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}": {
34966
+ get: {
34967
+ operationId: "readContactProjectMembership",
34968
+ summary: "Read one authoritative contact-project membership snapshot",
34969
+ parameters: [
34970
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
34971
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
34972
+ ],
34973
+ responses: {
34974
+ "200": {
34975
+ content: {
34976
+ "application/json": {
34977
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipSnapshot" }
34978
+ }
34979
+ }
34980
+ }
34981
+ }
34982
+ }
34983
+ },
34984
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}/attach": {
34985
+ post: {
34986
+ operationId: "attachContactProjectMembership",
34987
+ summary: "Attach a contact to a project under expected-version CAS with a replay-safe receipt",
34988
+ parameters: [
34989
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
34990
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
34991
+ ],
34992
+ requestBody: {
34993
+ required: true,
34994
+ content: {
34995
+ "application/json": {
34996
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationInput" }
34997
+ }
34998
+ }
34999
+ },
35000
+ responses: {
35001
+ "200": {
35002
+ content: {
35003
+ "application/json": {
35004
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationResult" }
35005
+ }
35006
+ }
35007
+ }
35008
+ }
35009
+ }
35010
+ },
35011
+ "/v1/projects/{project_id}/contact-memberships/{contact_id}/detach": {
35012
+ post: {
35013
+ operationId: "detachContactProjectMembership",
35014
+ summary: "Detach a contact from a project under expected-version CAS with a replay-safe receipt",
35015
+ parameters: [
35016
+ { name: "project_id", in: "path", required: true, schema: { type: "string" } },
35017
+ { name: "contact_id", in: "path", required: true, schema: { type: "string" } }
35018
+ ],
35019
+ requestBody: {
35020
+ required: true,
35021
+ content: {
35022
+ "application/json": {
35023
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationInput" }
35024
+ }
35025
+ }
35026
+ },
35027
+ responses: {
35028
+ "200": {
35029
+ content: {
35030
+ "application/json": {
35031
+ schema: { $ref: "#/components/schemas/ContactProjectMembershipMutationResult" }
35032
+ }
35033
+ }
35034
+ }
35035
+ }
35036
+ }
35037
+ },
34028
35038
  "/v1/stats": {
34029
35039
  get: {
34030
35040
  operationId: "getStats",