@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.
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)}`);
7840
+ }
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 });
7542
7849
  }
7543
- async unlinkContactFromProject() {
7544
- return unavailable("unlinkContactFromProject");
7850
+ async listContactIdsByProject(projectId) {
7851
+ return pick(await this.g(`/projects/${this.enc(projectId)}/contacts`), "contact_ids") ?? [];
7545
7852
  }
7546
- async getContactProjectIds() {
7547
- return unavailable("getContactProjectIds");
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) {
@@ -33708,6 +34020,102 @@ var init_pg_migrations = __esm(() => {
33708
34020
  CREATE INDEX IF NOT EXISTS idx_contacts_tombstones_deleted_at ON _contacts_tombstones(deleted_at);
33709
34021
 
33710
34022
  INSERT INTO _migrations (version) VALUES (12) ON CONFLICT DO NOTHING;
34023
+ `,
34024
+ `
34025
+ -- The contact_tags primary key is (contact_id, tag_id), which does not
34026
+ -- support the public tag_id filter efficiently. Keep this forward-only and
34027
+ -- idempotent for populated cloud deployments.
34028
+ CREATE INDEX IF NOT EXISTS idx_contact_tags_tag_contact ON contact_tags(tag_id, contact_id);
34029
+
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;
33711
34119
  `
33712
34120
  ];
33713
34121
  });
@@ -33810,6 +34218,7 @@ var init_cloud = __esm(() => {
33810
34218
  });
33811
34219
 
33812
34220
  // src/server/pg-store.ts
34221
+ import { createHash as createHash3 } from "crypto";
33813
34222
  function uuid3() {
33814
34223
  return crypto.randomUUID();
33815
34224
  }
@@ -33844,6 +34253,45 @@ function newUuid() {
33844
34253
  function nowIso() {
33845
34254
  return new Date().toISOString();
33846
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
+ }
33847
34295
  function parseJson(value) {
33848
34296
  if (!value)
33849
34297
  return {};
@@ -33911,12 +34359,87 @@ function mapTag(row) {
33911
34359
  created_at: iso(row.created_at)
33912
34360
  };
33913
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
+ }
33914
34385
 
33915
34386
  class ContactsPgStore {
33916
34387
  client;
33917
34388
  constructor(client) {
33918
34389
  this.client = client;
33919
34390
  }
34391
+ async attachTags(contacts) {
34392
+ const tagsByContactId = new Map(contacts.map((contact) => [contact.id, []]));
34393
+ if (contacts.length === 0)
34394
+ return [];
34395
+ const rows = await this.client.many(`SELECT ct.contact_id, t.*
34396
+ FROM contact_tags ct
34397
+ JOIN tags t ON t.id = ct.tag_id
34398
+ WHERE ct.contact_id = ANY($1::text[])
34399
+ ORDER BY t.name ASC`, [contacts.map((contact) => contact.id)]);
34400
+ for (const row of rows)
34401
+ tagsByContactId.get(row.contact_id)?.push(mapTag(row));
34402
+ return contacts.map((contact) => ({ ...contact, tags: tagsByContactId.get(contact.id) ?? [] }));
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
+ }
33920
34443
  async listContacts(filter = {}) {
33921
34444
  const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
33922
34445
  const offset = Math.max(filter.offset ?? 0, 0);
@@ -33930,6 +34453,10 @@ class ContactsPgStore {
33930
34453
  params.push(filter.status);
33931
34454
  where.push(`status = $${params.length}`);
33932
34455
  }
34456
+ if (filter.tag_id) {
34457
+ params.push(filter.tag_id);
34458
+ where.push(`EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = contacts.id AND ct.tag_id = $${params.length})`);
34459
+ }
33933
34460
  if (filter.q) {
33934
34461
  params.push(filter.q);
33935
34462
  where.push(`search_vector @@ plainto_tsquery('simple', $${params.length})`);
@@ -33938,11 +34465,13 @@ class ContactsPgStore {
33938
34465
  const countRow = await this.client.get(`SELECT COUNT(*)::text AS count FROM contacts ${whereSql}`, params);
33939
34466
  params.push(limit, offset);
33940
34467
  const rows = await this.client.many(`SELECT * FROM contacts ${whereSql} ORDER BY display_name ASC LIMIT $${params.length - 1} OFFSET $${params.length}`, params);
33941
- return { contacts: rows.map(mapContact), count: Number(countRow?.count ?? rows.length) };
34468
+ return { contacts: await this.attachContactDetails(rows.map(mapContact)), count: Number(countRow?.count ?? rows.length) };
33942
34469
  }
33943
34470
  async getContact(id) {
33944
34471
  const row = await this.client.get(`SELECT * FROM contacts WHERE id = $1`, [id]);
33945
- return row ? mapContact(row) : null;
34472
+ if (!row)
34473
+ return null;
34474
+ return (await this.attachContactDetails([mapContact(row)]))[0];
33946
34475
  }
33947
34476
  async createContact(input) {
33948
34477
  const id = uuid3();
@@ -33978,7 +34507,8 @@ class ContactsPgStore {
33978
34507
  input.priority ?? 3,
33979
34508
  input.timezone ?? null
33980
34509
  ]);
33981
- return mapContact(row);
34510
+ await this.insertContactMethods(id, input.emails, input.phones);
34511
+ return (await this.attachContactDetails([mapContact(row)]))[0];
33982
34512
  }
33983
34513
  async updateContact(id, input) {
33984
34514
  const allowed = {};
@@ -34013,17 +34543,192 @@ class ContactsPgStore {
34013
34543
  allowed.custom_fields = JSON.stringify(input.custom_fields);
34014
34544
  }
34015
34545
  const keys = Object.keys(allowed);
34016
- if (keys.length === 0)
34546
+ const hasMethodAppends = Boolean(input.emails_add?.length || input.phones_add?.length);
34547
+ if (keys.length === 0 && !hasMethodAppends)
34017
34548
  return this.getContact(id);
34018
34549
  const sets = keys.map((k, i) => `${k} = $${i + 2}`);
34019
34550
  sets.push(`updated_at = NOW()`);
34020
34551
  const row = await this.client.get(`UPDATE contacts SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, [id, ...keys.map((k) => allowed[k])]);
34021
- return row ? mapContact(row) : 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];
34022
34556
  }
34023
34557
  async deleteContact(id) {
34024
34558
  const result = await this.client.query(`DELETE FROM contacts WHERE id = $1`, [id]);
34025
34559
  return result.rowCount > 0;
34026
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
+ }
34027
34732
  async listCompanies(filter = {}) {
34028
34733
  const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
34029
34734
  const offset = Math.max(filter.offset ?? 0, 0);
@@ -35461,6 +36166,9 @@ function getContactsPgStore(client) {
35461
36166
  return cachedStore2;
35462
36167
  }
35463
36168
  var cachedStore2 = null;
36169
+ var init_pg_store = __esm(() => {
36170
+ init_project_memberships();
36171
+ });
35464
36172
 
35465
36173
  // src/server/v1.ts
35466
36174
  function json5(body, status = 200) {
@@ -35479,6 +36187,94 @@ async function readJson(req) {
35479
36187
  return null;
35480
36188
  }
35481
36189
  }
36190
+ function contactListFilterFromUrl(url) {
36191
+ return {
36192
+ ...url.searchParams.get("company_id") ? { company_id: url.searchParams.get("company_id") } : {},
36193
+ ...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
36194
+ ...url.searchParams.get("tag_id") ? { tag_id: url.searchParams.get("tag_id") } : {},
36195
+ ...url.searchParams.get("q") ? { q: url.searchParams.get("q") } : {},
36196
+ ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
36197
+ ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
36198
+ };
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
+ }
35482
36278
  async function handleV1Request(req, url) {
35483
36279
  const path = url.pathname;
35484
36280
  if (path !== "/v1" && !path.startsWith("/v1/"))
@@ -35513,6 +36309,9 @@ async function handleV1Request(req, url) {
35513
36309
  return v === null ? undefined : Number(v);
35514
36310
  };
35515
36311
  try {
36312
+ const projectLinks = await handleContactProjectsRoute(req, method, segments, store);
36313
+ if (projectLinks)
36314
+ return projectLinks;
35516
36315
  if (resource === "contacts" && id && sub) {
35517
36316
  if (sub === "tags") {
35518
36317
  const tagId = segments[4];
@@ -35592,13 +36391,7 @@ async function handleV1Request(req, url) {
35592
36391
  if (resource === "contacts") {
35593
36392
  if (!id) {
35594
36393
  if (method === "GET") {
35595
- const result = await store.listContacts({
35596
- ...url.searchParams.get("company_id") ? { company_id: url.searchParams.get("company_id") } : {},
35597
- ...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
35598
- ...url.searchParams.get("q") ? { q: url.searchParams.get("q") } : {},
35599
- ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
35600
- ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
35601
- });
36394
+ const result = await store.listContacts(contactListFilterFromUrl(url));
35602
36395
  return json5(result);
35603
36396
  }
35604
36397
  if (method === "POST") {
@@ -36220,6 +37013,8 @@ async function handleV1Request(req, url) {
36220
37013
  return error2(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
36221
37014
  } catch (e) {
36222
37015
  const msg = e.message || "internal error";
37016
+ if (e instanceof ContactProjectMembershipConflictError)
37017
+ return error2(409, msg);
36223
37018
  if (/violates|constraint|invalid input|duplicate key/i.test(msg))
36224
37019
  return error2(400, msg);
36225
37020
  return error2(500, msg);
@@ -36227,7 +37022,9 @@ async function handleV1Request(req, url) {
36227
37022
  }
36228
37023
  var JSON_HEADERS;
36229
37024
  var init_v1 = __esm(() => {
37025
+ init_project_memberships();
36230
37026
  init_cloud();
37027
+ init_pg_store();
36231
37028
  JSON_HEADERS = { "Content-Type": "application/json" };
36232
37029
  });
36233
37030
 
@@ -36352,6 +37149,58 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
36352
37149
  color: { type: "string" },
36353
37150
  description: { type: "string" }
36354
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
+ }
36355
37204
  }
36356
37205
  }
36357
37206
  },
@@ -36365,6 +37214,7 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
36365
37214
  { name: "q", in: "query", schema: { type: "string" } },
36366
37215
  { name: "company_id", in: "query", schema: { type: "string" } },
36367
37216
  { name: "status", in: "query", schema: { type: "string" } },
37217
+ { name: "tag_id", in: "query", schema: { type: "string" } },
36368
37218
  { name: "limit", in: "query", schema: { type: "number" } },
36369
37219
  { name: "offset", in: "query", schema: { type: "number" } }
36370
37220
  ],
@@ -36551,6 +37401,167 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
36551
37401
  })
36552
37402
  }
36553
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
+ },
36554
37565
  "/v1/stats": {
36555
37566
  get: {
36556
37567
  operationId: "getStats",
@@ -36570,6 +37581,7 @@ var init_openapi = __esm(() => {
36570
37581
  init_package_version();
36571
37582
  contactSchema = {
36572
37583
  type: "object",
37584
+ required: ["tags"],
36573
37585
  properties: {
36574
37586
  id: { type: "string" },
36575
37587
  first_name: { type: "string" },
@@ -36584,6 +37596,7 @@ var init_openapi = __esm(() => {
36584
37596
  sensitivity: { type: "string" },
36585
37597
  archived: { type: "boolean" },
36586
37598
  priority: { type: "number" },
37599
+ tags: { type: "array", items: { $ref: "#/components/schemas/Tag" } },
36587
37600
  created_at: { type: "string" },
36588
37601
  updated_at: { type: "string" }
36589
37602
  }
@@ -38615,6 +39628,41 @@ Group not found: ${groupId}
38615
39628
  await store.removeContactFromGroup(contactId, groupId);
38616
39629
  console.log(chalk2.green(`
38617
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}
38618
39666
  `));
38619
39667
  });
38620
39668
  program2.command("init").description("Show setup info, stats, and configuration").action(async () => {