@hasna/contacts 0.6.34 → 0.6.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/dist/assets/index-0l6aQb1t.css +1 -0
- package/dashboard/dist/assets/index-opnZdkVD.js +229 -0
- package/dashboard/dist/index.html +13 -0
- package/dist/cli/commands/advanced.d.ts +3 -0
- package/dist/cli/commands/advanced.d.ts.map +1 -0
- package/dist/cli/commands/audience.d.ts +18 -0
- package/dist/cli/commands/audience.d.ts.map +1 -0
- package/dist/cli/commands/core.d.ts +3 -0
- package/dist/cli/commands/core.d.ts.map +1 -0
- package/dist/cli/commands/crm.d.ts +3 -0
- package/dist/cli/commands/crm.d.ts.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +1060 -46
- package/dist/cli/storage.d.ts +3 -0
- package/dist/cli/storage.d.ts.map +1 -0
- package/dist/cli/utils.d.ts +10 -0
- package/dist/cli/utils.d.ts.map +1 -0
- package/dist/cloud/http-storage.d.ts +88 -0
- package/dist/cloud/http-storage.d.ts.map +1 -0
- package/dist/db/activity.d.ts +15 -0
- package/dist/db/activity.d.ts.map +1 -0
- package/dist/db/applications.d.ts +10 -0
- package/dist/db/applications.d.ts.map +1 -0
- package/dist/db/audiences.d.ts +51 -0
- package/dist/db/audiences.d.ts.map +1 -0
- package/dist/db/companies.d.ts +19 -0
- package/dist/db/companies.d.ts.map +1 -0
- package/dist/db/contact-tasks.d.ts +19 -0
- package/dist/db/contact-tasks.d.ts.map +1 -0
- package/dist/db/contacts.d.ts +30 -0
- package/dist/db/contacts.d.ts.map +1 -0
- package/dist/db/coordination.d.ts +30 -0
- package/dist/db/coordination.d.ts.map +1 -0
- package/dist/db/database.d.ts +8 -0
- package/dist/db/database.d.ts.map +1 -0
- package/dist/db/deals.d.ts +14 -0
- package/dist/db/deals.d.ts.map +1 -0
- package/dist/db/documents.d.ts +38 -0
- package/dist/db/documents.d.ts.map +1 -0
- package/dist/db/events.d.ts +14 -0
- package/dist/db/events.d.ts.map +1 -0
- package/dist/db/field-history.d.ts +17 -0
- package/dist/db/field-history.d.ts.map +1 -0
- package/dist/db/freshness.d.ts +24 -0
- package/dist/db/freshness.d.ts.map +1 -0
- package/dist/db/graph.d.ts +21 -0
- package/dist/db/graph.d.ts.map +1 -0
- package/dist/db/groups.d.ts +22 -0
- package/dist/db/groups.d.ts.map +1 -0
- package/dist/db/health.d.ts +40 -0
- package/dist/db/health.d.ts.map +1 -0
- package/dist/db/identity.d.ts +32 -0
- package/dist/db/identity.d.ts.map +1 -0
- package/dist/db/job-history.d.ts +29 -0
- package/dist/db/job-history.d.ts.map +1 -0
- package/dist/db/learnings.d.ts +43 -0
- package/dist/db/learnings.d.ts.map +1 -0
- package/dist/db/notes.d.ts +8 -0
- package/dist/db/notes.d.ts.map +1 -0
- package/dist/db/org-chart.d.ts +37 -0
- package/dist/db/org-chart.d.ts.map +1 -0
- package/dist/db/org-members.d.ts +9 -0
- package/dist/db/org-members.d.ts.map +1 -0
- package/dist/db/paths.d.ts +3 -0
- package/dist/db/paths.d.ts.map +1 -0
- package/dist/db/pg-migrations.d.ts +8 -0
- package/dist/db/pg-migrations.d.ts.map +1 -0
- package/dist/db/project-memberships.d.ts +8 -0
- package/dist/db/project-memberships.d.ts.map +1 -0
- package/dist/db/relationships.d.ts +30 -0
- package/dist/db/relationships.d.ts.map +1 -0
- package/dist/db/signals.d.ts +18 -0
- package/dist/db/signals.d.ts.map +1 -0
- package/dist/db/sqlite-adapter.d.ts +15 -0
- package/dist/db/sqlite-adapter.d.ts.map +1 -0
- package/dist/db/storage.d.ts +15 -0
- package/dist/db/storage.d.ts.map +1 -0
- package/dist/db/tags.d.ts +14 -0
- package/dist/db/tags.d.ts.map +1 -0
- package/dist/db/tombstones.d.ts +19 -0
- package/dist/db/tombstones.d.ts.map +1 -0
- package/dist/db/vendor-comms.d.ts +15 -0
- package/dist/db/vendor-comms.d.ts.map +1 -0
- package/dist/generated/storage-kit/health.d.ts +20 -0
- package/dist/generated/storage-kit/health.d.ts.map +1 -0
- package/dist/generated/storage-kit/index.d.ts +8 -0
- package/dist/generated/storage-kit/index.d.ts.map +1 -0
- package/dist/generated/storage-kit/migrations.d.ts +48 -0
- package/dist/generated/storage-kit/migrations.d.ts.map +1 -0
- package/dist/generated/storage-kit/mode.d.ts +48 -0
- package/dist/generated/storage-kit/mode.d.ts.map +1 -0
- package/dist/generated/storage-kit/pool.d.ts +34 -0
- package/dist/generated/storage-kit/pool.d.ts.map +1 -0
- package/dist/generated/storage-kit/query.d.ts +36 -0
- package/dist/generated/storage-kit/query.d.ts.map +1 -0
- package/dist/generated/storage-kit/tls.d.ts +26 -0
- package/dist/generated/storage-kit/tls.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +613 -23
- package/dist/lib/apple-contacts.d.ts +3 -0
- package/dist/lib/apple-contacts.d.ts.map +1 -0
- package/dist/lib/audience-contract.d.ts +215 -0
- package/dist/lib/audience-contract.d.ts.map +1 -0
- package/dist/lib/audit.d.ts +12 -0
- package/dist/lib/audit.d.ts.map +1 -0
- package/dist/lib/brief.d.ts +3 -0
- package/dist/lib/brief.d.ts.map +1 -0
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/connector.d.ts +44 -0
- package/dist/lib/connector.d.ts.map +1 -0
- package/dist/lib/context.d.ts +5 -0
- package/dist/lib/context.d.ts.map +1 -0
- package/dist/lib/dedup.d.ts +10 -0
- package/dist/lib/dedup.d.ts.map +1 -0
- package/dist/lib/document-scanner.d.ts +8 -0
- package/dist/lib/document-scanner.d.ts.map +1 -0
- package/dist/lib/embeddings.d.ts +9 -0
- package/dist/lib/embeddings.d.ts.map +1 -0
- package/dist/lib/export.d.ts +8 -0
- package/dist/lib/export.d.ts.map +1 -0
- package/dist/lib/freshness.d.ts +19 -0
- package/dist/lib/freshness.d.ts.map +1 -0
- package/dist/lib/gmail-import.d.ts +51 -0
- package/dist/lib/gmail-import.d.ts.map +1 -0
- package/dist/lib/google-contacts.d.ts +115 -0
- package/dist/lib/google-contacts.d.ts.map +1 -0
- package/dist/lib/images.d.ts +30 -0
- package/dist/lib/images.d.ts.map +1 -0
- package/dist/lib/import.d.ts +10 -0
- package/dist/lib/import.d.ts.map +1 -0
- package/dist/lib/learning-maintenance.d.ts +8 -0
- package/dist/lib/learning-maintenance.d.ts.map +1 -0
- package/dist/lib/mailery-sync.d.ts +46 -0
- package/dist/lib/mailery-sync.d.ts.map +1 -0
- package/dist/lib/meeting-capture.d.ts +15 -0
- package/dist/lib/meeting-capture.d.ts.map +1 -0
- package/dist/lib/package-version.d.ts +6 -0
- package/dist/lib/package-version.d.ts.map +1 -0
- package/dist/lib/signals.d.ts +9 -0
- package/dist/lib/signals.d.ts.map +1 -0
- package/dist/lib/signature-parser.d.ts +36 -0
- package/dist/lib/signature-parser.d.ts.map +1 -0
- package/dist/lib/stats.d.ts +24 -0
- package/dist/lib/stats.d.ts.map +1 -0
- package/dist/lib/timeline.d.ts +11 -0
- package/dist/lib/timeline.d.ts.map +1 -0
- package/dist/lib/upcoming.d.ts +14 -0
- package/dist/lib/upcoming.d.ts.map +1 -0
- package/dist/lib/vault.d.ts +24 -0
- package/dist/lib/vault.d.ts.map +1 -0
- package/dist/mcp/handlers/advanced.d.ts +14 -0
- package/dist/mcp/handlers/advanced.d.ts.map +1 -0
- package/dist/mcp/handlers/audience.d.ts +9 -0
- package/dist/mcp/handlers/audience.d.ts.map +1 -0
- package/dist/mcp/handlers/core.d.ts +10 -0
- package/dist/mcp/handlers/core.d.ts.map +1 -0
- package/dist/mcp/handlers/crm.d.ts +14 -0
- package/dist/mcp/handlers/crm.d.ts.map +1 -0
- package/dist/mcp/handlers/index.d.ts +4 -0
- package/dist/mcp/handlers/index.d.ts.map +1 -0
- package/dist/mcp/handlers/types.d.ts +10 -0
- package/dist/mcp/handlers/types.d.ts.map +1 -0
- package/dist/mcp/http.d.ts +17 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +339 -27
- package/dist/mcp/register-tools.d.ts +3 -0
- package/dist/mcp/register-tools.d.ts.map +1 -0
- package/dist/mcp/schema.d.ts +3 -0
- package/dist/mcp/schema.d.ts.map +1 -0
- package/dist/mcp/storage-tools.d.ts +3 -0
- package/dist/mcp/storage-tools.d.ts.map +1 -0
- package/dist/mcp/tools.d.ts +3799 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/sdk/index.d.ts +16 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +63 -0
- package/dist/sdk/v1.generated.d.ts +270 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -0
- package/dist/server/cloud-index.d.ts +3 -0
- package/dist/server/cloud-index.d.ts.map +1 -0
- package/dist/server/cloud-serve.d.ts +3 -0
- package/dist/server/cloud-serve.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +53 -0
- package/dist/server/cloud.d.ts.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1022 -46
- package/dist/server/openapi.d.ts +1134 -0
- package/dist/server/openapi.d.ts.map +1 -0
- package/dist/server/pg-store.d.ts +893 -0
- package/dist/server/pg-store.d.ts.map +1 -0
- package/dist/server/security.d.ts +24 -0
- package/dist/server/security.d.ts.map +1 -0
- package/dist/server/serve.d.ts +9 -0
- package/dist/server/serve.d.ts.map +1 -0
- package/dist/server/v1.d.ts +28 -0
- package/dist/server/v1.d.ts.map +1 -0
- package/dist/store/index.d.ts +387 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +856 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/project-memberships.d.ts +32 -0
- package/dist/types/project-memberships.d.ts.map +1 -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 -9187
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) {
|
|
@@ -9024,6 +9313,94 @@ var init_pg_migrations = __esm(() => {
|
|
|
9024
9313
|
CREATE INDEX IF NOT EXISTS idx_contact_tags_tag_contact ON contact_tags(tag_id, contact_id);
|
|
9025
9314
|
|
|
9026
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;
|
|
9027
9404
|
`
|
|
9028
9405
|
];
|
|
9029
9406
|
});
|
|
@@ -10059,7 +10436,7 @@ function listImages() {
|
|
|
10059
10436
|
init_database();
|
|
10060
10437
|
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
10061
10438
|
import { join as join3 } from "path";
|
|
10062
|
-
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto";
|
|
10439
|
+
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash as createHash2 } from "crypto";
|
|
10063
10440
|
var VAULT_DIR = getDataDir();
|
|
10064
10441
|
var VAULT_CONFIG = join3(VAULT_DIR, "vault.json");
|
|
10065
10442
|
var VAULT_SESSION = join3(VAULT_DIR, ".vault-session");
|
|
@@ -10105,7 +10482,7 @@ function initVault(passphrase) {
|
|
|
10105
10482
|
mkdirSync4(DOCUMENTS_DIR, { recursive: true });
|
|
10106
10483
|
const salt = randomBytes(32);
|
|
10107
10484
|
const key = deriveKey(passphrase, salt);
|
|
10108
|
-
const keyHash =
|
|
10485
|
+
const keyHash = createHash2("sha256").update(key).digest("hex");
|
|
10109
10486
|
const config = { salt: salt.toString("hex"), key_hash: keyHash, created_at: new Date().toISOString() };
|
|
10110
10487
|
writeFileSync2(VAULT_CONFIG, JSON.stringify(config, null, 2));
|
|
10111
10488
|
_derivedKey = key;
|
|
@@ -10120,7 +10497,7 @@ function unlockVault(passphrase) {
|
|
|
10120
10497
|
const config = JSON.parse(readFileSync2(VAULT_CONFIG, "utf-8"));
|
|
10121
10498
|
const salt = Buffer.from(config.salt, "hex");
|
|
10122
10499
|
const key = deriveKey(passphrase, salt);
|
|
10123
|
-
const keyHash =
|
|
10500
|
+
const keyHash = createHash2("sha256").update(key).digest("hex");
|
|
10124
10501
|
if (keyHash !== config.key_hash)
|
|
10125
10502
|
return false;
|
|
10126
10503
|
_derivedKey = key;
|
|
@@ -10251,7 +10628,7 @@ var exports_util = {};
|
|
|
10251
10628
|
__export(exports_util, {
|
|
10252
10629
|
unwrapMessage: () => unwrapMessage,
|
|
10253
10630
|
stringifyPrimitive: () => stringifyPrimitive,
|
|
10254
|
-
required: () =>
|
|
10631
|
+
required: () => required2,
|
|
10255
10632
|
randomString: () => randomString,
|
|
10256
10633
|
propertyKeyTypes: () => propertyKeyTypes,
|
|
10257
10634
|
promiseAllObject: () => promiseAllObject,
|
|
@@ -10662,7 +11039,7 @@ function partial(Class, schema, mask) {
|
|
|
10662
11039
|
checks: []
|
|
10663
11040
|
});
|
|
10664
11041
|
}
|
|
10665
|
-
function
|
|
11042
|
+
function required2(Class, schema, mask) {
|
|
10666
11043
|
const oldShape = schema._zod.def.shape;
|
|
10667
11044
|
const shape = { ...oldShape };
|
|
10668
11045
|
if (mask) {
|
|
@@ -21104,7 +21481,7 @@ function parseObjectDef(def, refs) {
|
|
|
21104
21481
|
type: "object",
|
|
21105
21482
|
properties: {}
|
|
21106
21483
|
};
|
|
21107
|
-
const
|
|
21484
|
+
const required3 = [];
|
|
21108
21485
|
const shape = def.shape();
|
|
21109
21486
|
for (const propName in shape) {
|
|
21110
21487
|
let propDef = shape[propName];
|
|
@@ -21131,11 +21508,11 @@ function parseObjectDef(def, refs) {
|
|
|
21131
21508
|
}
|
|
21132
21509
|
result.properties[propName] = parsedDef;
|
|
21133
21510
|
if (!propOptional) {
|
|
21134
|
-
|
|
21511
|
+
required3.push(propName);
|
|
21135
21512
|
}
|
|
21136
21513
|
}
|
|
21137
|
-
if (
|
|
21138
|
-
result.required =
|
|
21514
|
+
if (required3.length) {
|
|
21515
|
+
result.required = required3;
|
|
21139
21516
|
}
|
|
21140
21517
|
const additionalProperties = decideAdditionalProperties(def, refs);
|
|
21141
21518
|
if (additionalProperties !== undefined) {
|
|
@@ -23894,6 +24271,7 @@ function getStorageStatus(db = getDatabase()) {
|
|
|
23894
24271
|
|
|
23895
24272
|
// src/store/index.ts
|
|
23896
24273
|
init_contacts();
|
|
24274
|
+
init_project_memberships2();
|
|
23897
24275
|
|
|
23898
24276
|
// src/db/groups.ts
|
|
23899
24277
|
init_database();
|
|
@@ -26496,6 +26874,15 @@ class LocalStore {
|
|
|
26496
26874
|
async listContactIdsByProject(projectId) {
|
|
26497
26875
|
return listContactIdsByProject(projectId, this.db);
|
|
26498
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
|
+
}
|
|
26499
26886
|
async createCompany(input) {
|
|
26500
26887
|
return createCompany(input, this.db);
|
|
26501
26888
|
}
|
|
@@ -27118,20 +27505,33 @@ class ApiStore {
|
|
|
27118
27505
|
async findContactByEmailAddress(address) {
|
|
27119
27506
|
return this.getContactByEmail(address);
|
|
27120
27507
|
}
|
|
27121
|
-
async linkContactToProject() {
|
|
27122
|
-
|
|
27508
|
+
async linkContactToProject(contactId, projectId) {
|
|
27509
|
+
await this.client.transport.put(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
|
|
27123
27510
|
}
|
|
27124
|
-
async unlinkContactFromProject() {
|
|
27125
|
-
|
|
27511
|
+
async unlinkContactFromProject(contactId, projectId) {
|
|
27512
|
+
await this.del(`/contacts/${this.enc(contactId)}/projects/${this.enc(projectId)}`);
|
|
27126
27513
|
}
|
|
27127
|
-
async getContactProjectIds() {
|
|
27128
|
-
return
|
|
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 });
|
|
27129
27519
|
}
|
|
27130
|
-
async
|
|
27131
|
-
return
|
|
27520
|
+
async listContactIdsByProject(projectId) {
|
|
27521
|
+
return pick2(await this.g(`/projects/${this.enc(projectId)}/contacts`), "contact_ids") ?? [];
|
|
27132
27522
|
}
|
|
27133
|
-
async
|
|
27134
|
-
return
|
|
27523
|
+
async readContactProjectMembership(contactId, projectId) {
|
|
27524
|
+
return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships/${this.enc(contactId)}`);
|
|
27525
|
+
}
|
|
27526
|
+
async listContactProjectMemberships(projectId, maxItems) {
|
|
27527
|
+
return this.client.transport.get(`/projects/${this.enc(projectId)}/contact-memberships`, { query: { max_items: maxItems } });
|
|
27528
|
+
}
|
|
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
|
+
});
|
|
27135
27535
|
}
|
|
27136
27536
|
async createCompany(input) {
|
|
27137
27537
|
const res = await this.client.create("companies", stripUndefined(input));
|
|
@@ -29024,8 +29424,8 @@ var _contactsAgents = new Map;
|
|
|
29024
29424
|
var advancedHandlers = {
|
|
29025
29425
|
get_field_history: async (a) => json3({ history: await getStore().getFieldHistory(a.contact_id, a.field_name) }),
|
|
29026
29426
|
get_contact_at: async (a) => {
|
|
29027
|
-
const
|
|
29028
|
-
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 });
|
|
29029
29429
|
},
|
|
29030
29430
|
get_job_history: async (a) => json3({ history: await getStore().getJobHistory(a.contact_id) }),
|
|
29031
29431
|
add_job_entry: async (a) => json3(await getStore().addJobEntry(a.contact_id, {
|
|
@@ -29501,11 +29901,11 @@ function jsonSchemaToZodType(schema) {
|
|
|
29501
29901
|
}
|
|
29502
29902
|
function jsonSchemaToZodObject(schema) {
|
|
29503
29903
|
const objectSchema = schema && typeof schema === "object" ? schema : {};
|
|
29504
|
-
const
|
|
29904
|
+
const required3 = new Set(objectSchema.required ?? []);
|
|
29505
29905
|
const shape = {};
|
|
29506
29906
|
for (const [key, value] of Object.entries(objectSchema.properties ?? {})) {
|
|
29507
29907
|
const propertySchema = jsonSchemaToZodType(value);
|
|
29508
|
-
shape[key] =
|
|
29908
|
+
shape[key] = required3.has(key) ? propertySchema : propertySchema.optional();
|
|
29509
29909
|
}
|
|
29510
29910
|
return describeSchema(exports_external.object(shape).passthrough(), objectSchema.description);
|
|
29511
29911
|
}
|
|
@@ -31185,11 +31585,11 @@ function isLoopbackBindHost(hostname2) {
|
|
|
31185
31585
|
const normalized = hostname2.trim().toLowerCase();
|
|
31186
31586
|
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(normalized);
|
|
31187
31587
|
}
|
|
31188
|
-
function hasScope(principal,
|
|
31189
|
-
const prefix =
|
|
31190
|
-
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}:*`);
|
|
31191
31591
|
}
|
|
31192
|
-
function authenticateContactsRequest(req,
|
|
31592
|
+
function authenticateContactsRequest(req, required3, context = { allowUnauthenticatedLoopback: false }) {
|
|
31193
31593
|
const configured = parseTokenRecords();
|
|
31194
31594
|
const token = bearerToken(req) ?? req.headers.get("x-contacts-token");
|
|
31195
31595
|
if (token) {
|
|
@@ -31197,8 +31597,8 @@ function authenticateContactsRequest(req, required2, context = { allowUnauthenti
|
|
|
31197
31597
|
if (!matched)
|
|
31198
31598
|
return { ok: false, status: 401, message: "Invalid contacts token" };
|
|
31199
31599
|
const principal = { id: "api-token", scopes: matched.scopes, localDevelopment: false };
|
|
31200
|
-
if (!hasScope(principal,
|
|
31201
|
-
return { ok: false, status: 403, message: `Missing scope: ${
|
|
31600
|
+
if (!hasScope(principal, required3))
|
|
31601
|
+
return { ok: false, status: 403, message: `Missing scope: ${required3}` };
|
|
31202
31602
|
return { ok: true, principal };
|
|
31203
31603
|
}
|
|
31204
31604
|
if (configured.length === 0 && context.allowUnauthenticatedLoopback) {
|
|
@@ -31249,9 +31649,12 @@ function redactContactForExport(contact) {
|
|
|
31249
31649
|
}
|
|
31250
31650
|
|
|
31251
31651
|
// src/server/v1.ts
|
|
31652
|
+
init_project_memberships();
|
|
31252
31653
|
init_cloud();
|
|
31253
31654
|
|
|
31254
31655
|
// src/server/pg-store.ts
|
|
31656
|
+
init_project_memberships();
|
|
31657
|
+
import { createHash as createHash3 } from "crypto";
|
|
31255
31658
|
function uuid3() {
|
|
31256
31659
|
return crypto.randomUUID();
|
|
31257
31660
|
}
|
|
@@ -31286,6 +31689,45 @@ function newUuid() {
|
|
|
31286
31689
|
function nowIso() {
|
|
31287
31690
|
return new Date().toISOString();
|
|
31288
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
|
+
}
|
|
31289
31731
|
function parseJson(value) {
|
|
31290
31732
|
if (!value)
|
|
31291
31733
|
return {};
|
|
@@ -31353,6 +31795,29 @@ function mapTag(row) {
|
|
|
31353
31795
|
created_at: iso(row.created_at)
|
|
31354
31796
|
};
|
|
31355
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
|
+
}
|
|
31356
31821
|
|
|
31357
31822
|
class ContactsPgStore {
|
|
31358
31823
|
client;
|
|
@@ -31372,6 +31837,45 @@ class ContactsPgStore {
|
|
|
31372
31837
|
tagsByContactId.get(row.contact_id)?.push(mapTag(row));
|
|
31373
31838
|
return contacts.map((contact) => ({ ...contact, tags: tagsByContactId.get(contact.id) ?? [] }));
|
|
31374
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
|
+
}
|
|
31375
31879
|
async listContacts(filter = {}) {
|
|
31376
31880
|
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
|
|
31377
31881
|
const offset = Math.max(filter.offset ?? 0, 0);
|
|
@@ -31397,13 +31901,13 @@ class ContactsPgStore {
|
|
|
31397
31901
|
const countRow = await this.client.get(`SELECT COUNT(*)::text AS count FROM contacts ${whereSql}`, params);
|
|
31398
31902
|
params.push(limit, offset);
|
|
31399
31903
|
const rows = await this.client.many(`SELECT * FROM contacts ${whereSql} ORDER BY display_name ASC LIMIT $${params.length - 1} OFFSET $${params.length}`, params);
|
|
31400
|
-
return { contacts: await this.
|
|
31904
|
+
return { contacts: await this.attachContactDetails(rows.map(mapContact)), count: Number(countRow?.count ?? rows.length) };
|
|
31401
31905
|
}
|
|
31402
31906
|
async getContact(id) {
|
|
31403
31907
|
const row = await this.client.get(`SELECT * FROM contacts WHERE id = $1`, [id]);
|
|
31404
31908
|
if (!row)
|
|
31405
31909
|
return null;
|
|
31406
|
-
return (await this.
|
|
31910
|
+
return (await this.attachContactDetails([mapContact(row)]))[0];
|
|
31407
31911
|
}
|
|
31408
31912
|
async createContact(input) {
|
|
31409
31913
|
const id = uuid3();
|
|
@@ -31439,7 +31943,8 @@ class ContactsPgStore {
|
|
|
31439
31943
|
input.priority ?? 3,
|
|
31440
31944
|
input.timezone ?? null
|
|
31441
31945
|
]);
|
|
31442
|
-
|
|
31946
|
+
await this.insertContactMethods(id, input.emails, input.phones);
|
|
31947
|
+
return (await this.attachContactDetails([mapContact(row)]))[0];
|
|
31443
31948
|
}
|
|
31444
31949
|
async updateContact(id, input) {
|
|
31445
31950
|
const allowed = {};
|
|
@@ -31474,17 +31979,192 @@ class ContactsPgStore {
|
|
|
31474
31979
|
allowed.custom_fields = JSON.stringify(input.custom_fields);
|
|
31475
31980
|
}
|
|
31476
31981
|
const keys = Object.keys(allowed);
|
|
31477
|
-
|
|
31982
|
+
const hasMethodAppends = Boolean(input.emails_add?.length || input.phones_add?.length);
|
|
31983
|
+
if (keys.length === 0 && !hasMethodAppends)
|
|
31478
31984
|
return this.getContact(id);
|
|
31479
31985
|
const sets = keys.map((k, i) => `${k} = $${i + 2}`);
|
|
31480
31986
|
sets.push(`updated_at = NOW()`);
|
|
31481
31987
|
const row = await this.client.get(`UPDATE contacts SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, [id, ...keys.map((k) => allowed[k])]);
|
|
31482
|
-
|
|
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];
|
|
31483
31992
|
}
|
|
31484
31993
|
async deleteContact(id) {
|
|
31485
31994
|
const result = await this.client.query(`DELETE FROM contacts WHERE id = $1`, [id]);
|
|
31486
31995
|
return result.rowCount > 0;
|
|
31487
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
|
+
}
|
|
31488
32168
|
async listCompanies(filter = {}) {
|
|
31489
32169
|
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
|
|
31490
32170
|
const offset = Math.max(filter.offset ?? 0, 0);
|
|
@@ -32951,6 +33631,84 @@ function contactListFilterFromUrl(url) {
|
|
|
32951
33631
|
...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
|
|
32952
33632
|
};
|
|
32953
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
|
+
}
|
|
32954
33712
|
async function handleV1Request(req, url) {
|
|
32955
33713
|
const path = url.pathname;
|
|
32956
33714
|
if (path !== "/v1" && !path.startsWith("/v1/"))
|
|
@@ -32985,6 +33743,9 @@ async function handleV1Request(req, url) {
|
|
|
32985
33743
|
return v === null ? undefined : Number(v);
|
|
32986
33744
|
};
|
|
32987
33745
|
try {
|
|
33746
|
+
const projectLinks = await handleContactProjectsRoute(req, method, segments, store);
|
|
33747
|
+
if (projectLinks)
|
|
33748
|
+
return projectLinks;
|
|
32988
33749
|
if (resource === "contacts" && id && sub) {
|
|
32989
33750
|
if (sub === "tags") {
|
|
32990
33751
|
const tagId = segments[4];
|
|
@@ -33686,6 +34447,8 @@ async function handleV1Request(req, url) {
|
|
|
33686
34447
|
return error2(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
|
|
33687
34448
|
} catch (e) {
|
|
33688
34449
|
const msg = e.message || "internal error";
|
|
34450
|
+
if (e instanceof ContactProjectMembershipConflictError)
|
|
34451
|
+
return error2(409, msg);
|
|
33689
34452
|
if (/violates|constraint|invalid input|duplicate key/i.test(msg))
|
|
33690
34453
|
return error2(400, msg);
|
|
33691
34454
|
return error2(500, msg);
|
|
@@ -33859,6 +34622,58 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
|
|
|
33859
34622
|
color: { type: "string" },
|
|
33860
34623
|
description: { type: "string" }
|
|
33861
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
|
+
}
|
|
33862
34677
|
}
|
|
33863
34678
|
}
|
|
33864
34679
|
},
|
|
@@ -34059,6 +34874,167 @@ function buildV1OpenApiDocument(version2 = getPackageVersion()) {
|
|
|
34059
34874
|
})
|
|
34060
34875
|
}
|
|
34061
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
|
+
},
|
|
34062
35038
|
"/v1/stats": {
|
|
34063
35039
|
get: {
|
|
34064
35040
|
operationId: "getStats",
|