@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/commands/core.d.ts.map +1 -1
- package/dist/cli/index.js +1101 -53
- package/dist/db/contacts.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/project-memberships.d.ts +8 -0
- package/dist/db/project-memberships.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +616 -23
- package/dist/mcp/index.js +339 -27
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +63 -0
- package/dist/sdk/v1.generated.d.ts +67 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +1063 -53
- package/dist/server/openapi.d.ts +369 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/pg-store.d.ts +45 -13
- package/dist/server/pg-store.d.ts.map +1 -1
- package/dist/server/v1.d.ts +22 -0
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/store/index.d.ts +4 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/types/project-memberships.d.ts +32 -0
- package/dist/types/project-memberships.d.ts.map +1 -0
- package/migrations/0013_remote_delete_tombstones.sql +15 -0
- package/migrations/0014_contact_tag_filter_index.sql +9 -0
- package/migrations/0015_contact_project_membership_authority.sql +88 -0
- package/package.json +1 -1
- package/vendor/fast-uri/README.md +12 -0
- package/dist/server/cloud-index.js +0 -9153
package/dist/cli/index.js
CHANGED
|
@@ -2210,7 +2210,10 @@ function uuid() {
|
|
|
2210
2210
|
return crypto.randomUUID();
|
|
2211
2211
|
}
|
|
2212
2212
|
function now2() {
|
|
2213
|
-
|
|
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
|
-
|
|
3882
|
+
setContactProjectMembershipWithoutReceipt(contactId, projectId, true, d);
|
|
3592
3883
|
}
|
|
3593
3884
|
function unlinkContactFromProject(contactId, projectId, db) {
|
|
3594
3885
|
const d = db || getDatabase();
|
|
3595
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
7544
|
-
return
|
|
7850
|
+
async listContactIdsByProject(projectId) {
|
|
7851
|
+
return pick(await this.g(`/projects/${this.enc(projectId)}/contacts`), "contact_ids") ?? [];
|
|
7545
7852
|
}
|
|
7546
|
-
async
|
|
7547
|
-
return
|
|
7853
|
+
async readContactProjectMembership(contactId, projectId) {
|
|
7854
|
+
return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships/${this.enc(contactId)}`);
|
|
7548
7855
|
}
|
|
7549
|
-
async
|
|
7550
|
-
return
|
|
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
|
|
7553
|
-
return
|
|
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: () =>
|
|
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
|
|
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
|
|
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
|
-
|
|
20095
|
+
required3.push(propName);
|
|
19784
20096
|
}
|
|
19785
20097
|
}
|
|
19786
|
-
if (
|
|
19787
|
-
result.required =
|
|
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:
|
|
27145
|
-
return Array.isArray(
|
|
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
|
|
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
|
|
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] =
|
|
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,
|
|
32786
|
-
const prefix =
|
|
32787
|
-
return principal.scopes.has("*") || principal.scopes.has(
|
|
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,
|
|
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,
|
|
32798
|
-
return { ok: false, status: 403, message: `Missing scope: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () => {
|