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