@talkpilot/core-db 1.3.87 → 1.3.89
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/README.md +65 -12
- package/dist/talkpilot/auditLogs/auditLogs.getters.d.ts +8 -0
- package/dist/talkpilot/auditLogs/auditLogs.getters.d.ts.map +1 -0
- package/dist/talkpilot/auditLogs/auditLogs.getters.js +26 -0
- package/dist/talkpilot/auditLogs/auditLogs.getters.js.map +1 -0
- package/dist/talkpilot/auditLogs/auditLogs.types.d.ts +11 -0
- package/dist/talkpilot/auditLogs/auditLogs.types.d.ts.map +1 -0
- package/dist/talkpilot/auditLogs/auditLogs.types.js +3 -0
- package/dist/talkpilot/auditLogs/auditLogs.types.js.map +1 -0
- package/dist/talkpilot/auditLogs/index.d.ts +3 -0
- package/dist/talkpilot/auditLogs/index.d.ts.map +1 -0
- package/dist/talkpilot/auditLogs/index.js +19 -0
- package/dist/talkpilot/auditLogs/index.js.map +1 -0
- package/dist/talkpilot/calls/calls.constants.d.ts +11 -1
- package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.constants.js +11 -1
- package/dist/talkpilot/calls/calls.constants.js.map +1 -1
- package/dist/talkpilot/calls/calls.getters.d.ts +8 -1
- package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.getters.js +216 -1
- package/dist/talkpilot/calls/calls.getters.js.map +1 -1
- package/dist/talkpilot/calls/calls.types.d.ts +45 -0
- package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
- package/dist/talkpilot/clients/clients.constants.d.ts +4 -0
- package/dist/talkpilot/clients/clients.constants.d.ts.map +1 -0
- package/dist/talkpilot/clients/clients.constants.js +7 -0
- package/dist/talkpilot/clients/clients.constants.js.map +1 -0
- package/dist/talkpilot/clients/clients.getters.d.ts +6 -0
- package/dist/talkpilot/clients/clients.getters.d.ts.map +1 -1
- package/dist/talkpilot/clients/clients.getters.js +62 -1
- package/dist/talkpilot/clients/clients.getters.js.map +1 -1
- package/dist/talkpilot/clients/clients.lifecycle.d.ts +10 -0
- package/dist/talkpilot/clients/clients.lifecycle.d.ts.map +1 -0
- package/dist/talkpilot/clients/clients.lifecycle.js +109 -0
- package/dist/talkpilot/clients/clients.lifecycle.js.map +1 -0
- package/dist/talkpilot/clients/clients.types.d.ts +53 -0
- package/dist/talkpilot/clients/clients.types.d.ts.map +1 -1
- package/dist/talkpilot/clients/index.d.ts +2 -0
- package/dist/talkpilot/clients/index.d.ts.map +1 -1
- package/dist/talkpilot/clients/index.js +2 -0
- package/dist/talkpilot/clients/index.js.map +1 -1
- package/dist/talkpilot/contextNotes/contextNotes.getters.d.ts +1 -7
- package/dist/talkpilot/contextNotes/contextNotes.getters.d.ts.map +1 -1
- package/dist/talkpilot/contextNotes/contextNotes.getters.js +1 -37
- package/dist/talkpilot/contextNotes/contextNotes.getters.js.map +1 -1
- package/dist/talkpilot/contextNotes/contextNotes.types.d.ts +0 -6
- package/dist/talkpilot/contextNotes/contextNotes.types.d.ts.map +1 -1
- package/dist/talkpilot/index.d.ts +1 -0
- package/dist/talkpilot/index.d.ts.map +1 -1
- package/dist/talkpilot/index.js +1 -0
- package/dist/talkpilot/index.js.map +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts +9 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts.map +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.js +70 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.js.map +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts +5 -0
- package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts.map +1 -1
- package/dist/test-utils/factories/index.d.ts +2 -0
- package/dist/test-utils/factories/index.d.ts.map +1 -1
- package/dist/test-utils/factories/index.js +2 -0
- package/dist/test-utils/factories/index.js.map +1 -1
- package/dist/test-utils/factories/talkpilot/clients.d.ts +14 -0
- package/dist/test-utils/factories/talkpilot/clients.d.ts.map +1 -0
- package/dist/test-utils/factories/talkpilot/clients.js +19 -0
- package/dist/test-utils/factories/talkpilot/clients.js.map +1 -0
- package/dist/test-utils/factories/talkpilot/results.d.ts +16 -0
- package/dist/test-utils/factories/talkpilot/results.d.ts.map +1 -0
- package/dist/test-utils/factories/talkpilot/results.js +40 -0
- package/dist/test-utils/factories/talkpilot/results.js.map +1 -0
- package/dist/utils/regex.utils.d.ts +2 -0
- package/dist/utils/regex.utils.d.ts.map +1 -0
- package/dist/utils/regex.utils.js +6 -0
- package/dist/utils/regex.utils.js.map +1 -0
- package/dist/websitalk/scanResources/scanResources.getters.d.ts.map +1 -1
- package/dist/websitalk/scanResources/scanResources.getters.js +2 -1
- package/dist/websitalk/scanResources/scanResources.getters.js.map +1 -1
- package/dist/websitalk/scanResources/scanResources.utils.d.ts +1 -1
- package/dist/websitalk/scanResources/scanResources.utils.d.ts.map +1 -1
- package/dist/websitalk/scanResources/scanResources.utils.js +3 -3
- package/dist/websitalk/scanResources/scanResources.utils.js.map +1 -1
- package/package.json +1 -1
- package/src/talkpilot/auditLogs/__tests__/auditLogs.spec.ts +91 -0
- package/src/talkpilot/auditLogs/auditLogs.getters.ts +26 -0
- package/src/talkpilot/auditLogs/auditLogs.types.ts +12 -0
- package/src/talkpilot/auditLogs/index.ts +2 -0
- package/src/talkpilot/calls/__tests__/calls.statistics.getters.spec.ts +559 -0
- package/src/talkpilot/calls/calls.constants.ts +14 -0
- package/src/talkpilot/calls/calls.getters.ts +320 -0
- package/src/talkpilot/calls/calls.types.ts +52 -0
- package/src/talkpilot/clients/__tests__/clients.getters.spec.ts +224 -0
- package/src/talkpilot/clients/__tests__/clients.lifecycle.spec.ts +194 -0
- package/src/talkpilot/clients/clients.constants.ts +4 -0
- package/src/talkpilot/clients/clients.getters.ts +91 -2
- package/src/talkpilot/clients/clients.lifecycle.ts +132 -0
- package/src/talkpilot/clients/clients.types.ts +58 -0
- package/src/talkpilot/clients/index.ts +2 -0
- package/src/talkpilot/contextNotes/__tests__/contextNotes.getters.spec.ts +0 -141
- package/src/talkpilot/contextNotes/contextNotes.getters.ts +1 -75
- package/src/talkpilot/contextNotes/contextNotes.types.ts +0 -7
- package/src/talkpilot/index.ts +1 -0
- package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +284 -13
- package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +89 -1
- package/src/talkpilot/phone_numbers/phone_numbers.types.ts +6 -0
- package/src/test-utils/factories/index.ts +2 -0
- package/src/test-utils/factories/talkpilot/clients.ts +21 -0
- package/src/test-utils/factories/talkpilot/results.ts +43 -0
- package/src/utils/regex.utils.ts +2 -0
- package/src/websitalk/scanResources/scanResources.getters.ts +2 -6
- package/src/websitalk/scanResources/scanResources.utils.ts +2 -3
- package/dist/configuration/gptLive.seed.d.ts +0 -2
- package/dist/configuration/gptLive.seed.d.ts.map +0 -1
- package/dist/configuration/gptLive.seed.js +0 -59
- package/dist/configuration/gptLive.seed.js.map +0 -1
- package/dist/talkpilot/clients/clients.quota.constants.d.ts +0 -2
- package/dist/talkpilot/clients/clients.quota.constants.d.ts.map +0 -1
- package/dist/talkpilot/clients/clients.quota.constants.js +0 -5
- package/dist/talkpilot/clients/clients.quota.constants.js.map +0 -1
- package/dist/talkpilot/clients/clients.quota.utils.d.ts +0 -8
- package/dist/talkpilot/clients/clients.quota.utils.d.ts.map +0 -1
- package/dist/talkpilot/clients/clients.quota.utils.js +0 -45
- package/dist/talkpilot/clients/clients.quota.utils.js.map +0 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clientExistsInAnyCollection,
|
|
3
|
+
deleteClientDataAcrossDomains,
|
|
4
|
+
} from "../clients.lifecycle";
|
|
5
|
+
import { getPhoneNumbersCollection } from "../../phone_numbers/phone_numbers.getter";
|
|
6
|
+
import * as auditLogsGetters from "../../auditLogs/auditLogs.getters";
|
|
7
|
+
import {
|
|
8
|
+
createMuniIssue,
|
|
9
|
+
generateMuniIssueId,
|
|
10
|
+
getAuditLogsCollection,
|
|
11
|
+
getScansCollection,
|
|
12
|
+
} from "../../../index";
|
|
13
|
+
import {
|
|
14
|
+
createPhoneNumber,
|
|
15
|
+
createMuniIssueInput,
|
|
16
|
+
createScanFixture,
|
|
17
|
+
} from "../../../test-utils/factories";
|
|
18
|
+
|
|
19
|
+
describe("db.clients.lifecycle", () => {
|
|
20
|
+
describe("clientExistsInAnyCollection", () => {
|
|
21
|
+
it("should return true when the client exists only in phone_numbers (client_id field)", async () => {
|
|
22
|
+
const clientId = "existsPhoneOnly";
|
|
23
|
+
await getPhoneNumbersCollection().insertOne(
|
|
24
|
+
createPhoneNumber({ client_id: clientId }),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(await clientExistsInAnyCollection(clientId)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should return true when the client exists only in muniIssues (municipal DB)", async () => {
|
|
31
|
+
const clientId = "existsMuniOnly";
|
|
32
|
+
await createMuniIssue(
|
|
33
|
+
createMuniIssueInput({ clientId }),
|
|
34
|
+
generateMuniIssueId(),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(await clientExistsInAnyCollection(clientId)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return false when the client does not exist anywhere", async () => {
|
|
41
|
+
expect(await clientExistsInAnyCollection("noSuchClientAtAll")).toBe(
|
|
42
|
+
false,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should not consider websitalk data (scans/websiteUrls) at all", async () => {
|
|
47
|
+
const clientId = "onlyInWebsitalk";
|
|
48
|
+
await getScansCollection().insertOne(createScanFixture({ clientId }));
|
|
49
|
+
|
|
50
|
+
expect(await clientExistsInAnyCollection(clientId)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should not be fooled by unrelated collections lacking a clientId/client_id field", async () => {
|
|
54
|
+
await global.db
|
|
55
|
+
.collection("someUnrelatedCollection")
|
|
56
|
+
.insertOne({ someField: "someValue" });
|
|
57
|
+
|
|
58
|
+
expect(await clientExistsInAnyCollection("noSuchClientAtAll")).toBe(
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("deleteClientDataAcrossDomains", () => {
|
|
65
|
+
it("should return deleted:false with a reason and delete nothing when the client does not exist", async () => {
|
|
66
|
+
await getPhoneNumbersCollection().insertOne(
|
|
67
|
+
createPhoneNumber({ client_id: "unrelatedClient" }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const summary = await deleteClientDataAcrossDomains(
|
|
71
|
+
"missingClient",
|
|
72
|
+
"admin@callai.co.il",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(summary.deleted).toBe(false);
|
|
76
|
+
if (!summary.deleted) {
|
|
77
|
+
expect(summary.reason).toBeTruthy();
|
|
78
|
+
}
|
|
79
|
+
expect(
|
|
80
|
+
await getPhoneNumbersCollection().findOne({
|
|
81
|
+
client_id: "unrelatedClient",
|
|
82
|
+
}),
|
|
83
|
+
).not.toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should not write an audit log entry when the client does not exist", async () => {
|
|
87
|
+
await deleteClientDataAcrossDomains(
|
|
88
|
+
"missingClientNoAudit",
|
|
89
|
+
"admin@callai.co.il",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(
|
|
93
|
+
await getAuditLogsCollection().findOne({
|
|
94
|
+
clientId: "missingClientNoAudit",
|
|
95
|
+
}),
|
|
96
|
+
).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should delete the client's data across talkpilot and municipal domains", async () => {
|
|
100
|
+
const clientId = "deleteMeEverywhere";
|
|
101
|
+
await getPhoneNumbersCollection().insertOne(
|
|
102
|
+
createPhoneNumber({ client_id: clientId }),
|
|
103
|
+
);
|
|
104
|
+
await createMuniIssue(
|
|
105
|
+
createMuniIssueInput({ clientId }),
|
|
106
|
+
generateMuniIssueId(),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const adminEmail = "admin@callai.co.il";
|
|
110
|
+
const summary = await deleteClientDataAcrossDomains(
|
|
111
|
+
clientId,
|
|
112
|
+
adminEmail,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(summary.deleted).toBe(true);
|
|
116
|
+
if (summary.deleted) {
|
|
117
|
+
expect(summary.allSucceeded).toBe(true);
|
|
118
|
+
expect(summary.auditLogged).toBe(true);
|
|
119
|
+
expect(summary.auditError).toBeUndefined();
|
|
120
|
+
const byName = (name: string) =>
|
|
121
|
+
summary.results.find((r) => r.collectionName === name);
|
|
122
|
+
expect(byName("phone_numbers")?.deletedCount).toBe(1);
|
|
123
|
+
expect(byName("muniIssues")?.deletedCount).toBe(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(await clientExistsInAnyCollection(clientId)).toBe(false);
|
|
127
|
+
|
|
128
|
+
// The audit entry itself must survive the client's own deletion.
|
|
129
|
+
const auditEntry = await getAuditLogsCollection().findOne({ clientId });
|
|
130
|
+
expect(auditEntry).toMatchObject({
|
|
131
|
+
clientId,
|
|
132
|
+
adminEmail,
|
|
133
|
+
action: "delete",
|
|
134
|
+
});
|
|
135
|
+
expect(auditEntry?.timestamp).toBeInstanceOf(Date);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should still delete the data and report auditLogged: false when the audit write fails", async () => {
|
|
139
|
+
const clientId = "auditWriteFails";
|
|
140
|
+
await getPhoneNumbersCollection().insertOne(
|
|
141
|
+
createPhoneNumber({ client_id: clientId }),
|
|
142
|
+
);
|
|
143
|
+
const insertAuditLog = jest
|
|
144
|
+
.spyOn(auditLogsGetters, "insertAuditLog")
|
|
145
|
+
.mockRejectedValueOnce(new Error("audit write failed"));
|
|
146
|
+
|
|
147
|
+
const summary = await deleteClientDataAcrossDomains(
|
|
148
|
+
clientId,
|
|
149
|
+
"admin@callai.co.il",
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(summary).toMatchObject({
|
|
153
|
+
deleted: true,
|
|
154
|
+
allSucceeded: true,
|
|
155
|
+
auditLogged: false,
|
|
156
|
+
auditError: "audit write failed",
|
|
157
|
+
});
|
|
158
|
+
expect(await clientExistsInAnyCollection(clientId)).toBe(false);
|
|
159
|
+
|
|
160
|
+
insertAuditLog.mockRestore();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should not touch websitalk data (scans/websiteUrls) — callers must call the external website-talk deletion API separately", async () => {
|
|
164
|
+
const clientId = "hasWebsitalkData";
|
|
165
|
+
await getPhoneNumbersCollection().insertOne(
|
|
166
|
+
createPhoneNumber({ client_id: clientId }),
|
|
167
|
+
);
|
|
168
|
+
await getScansCollection().insertOne(createScanFixture({ clientId }));
|
|
169
|
+
|
|
170
|
+
await deleteClientDataAcrossDomains(clientId, "admin@callai.co.il");
|
|
171
|
+
|
|
172
|
+
expect(
|
|
173
|
+
await getScansCollection().findOne({ clientId }),
|
|
174
|
+
).not.toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("should not delete unrelated clients' data", async () => {
|
|
178
|
+
const targetClient = "deleteTarget";
|
|
179
|
+
const otherClient = "keepMeClient";
|
|
180
|
+
await getPhoneNumbersCollection().insertOne(
|
|
181
|
+
createPhoneNumber({ client_id: targetClient }),
|
|
182
|
+
);
|
|
183
|
+
await getPhoneNumbersCollection().insertOne(
|
|
184
|
+
createPhoneNumber({ client_id: otherClient }),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
await deleteClientDataAcrossDomains(targetClient, "admin@callai.co.il");
|
|
188
|
+
|
|
189
|
+
expect(
|
|
190
|
+
await getPhoneNumbersCollection().findOne({ client_id: otherClient }),
|
|
191
|
+
).not.toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import { getDb, Client } from "../index";
|
|
2
|
-
import { Collection } from "mongodb";
|
|
1
|
+
import { getDb, getPhoneNumbersCollection, Client } from "../index";
|
|
2
|
+
import { Collection, Filter } from "mongodb";
|
|
3
|
+
import { applyQueryOptions } from "../utils/query.utils";
|
|
4
|
+
import { escapeRegex } from "../../utils/regex.utils";
|
|
5
|
+
import {
|
|
6
|
+
BackofficeRole,
|
|
7
|
+
ClientsPage,
|
|
8
|
+
ClientsPageParams,
|
|
9
|
+
ClientsPageRow,
|
|
10
|
+
} from "./clients.types";
|
|
3
11
|
|
|
4
12
|
export const getClientsCollection = (): Collection<Client> => {
|
|
5
13
|
return getDb().collection<Client>("clients");
|
|
@@ -15,5 +23,86 @@ export const getClientByAPIKey = async (
|
|
|
15
23
|
return client ? client : null;
|
|
16
24
|
};
|
|
17
25
|
|
|
26
|
+
export const getAllClientIds = async (): Promise<string[]> => {
|
|
27
|
+
const docs = await getClientsCollection()
|
|
28
|
+
.find({}, { projection: { clientId: 1 } })
|
|
29
|
+
.toArray();
|
|
30
|
+
return docs.map((c) => c.clientId);
|
|
31
|
+
};
|
|
18
32
|
export const getClientByClientId = (clientId: string): Promise<Client | null> =>
|
|
19
33
|
getClientsCollection().findOne({ clientId });
|
|
34
|
+
|
|
35
|
+
export const getBackofficeRole = async (
|
|
36
|
+
clientId: string,
|
|
37
|
+
): Promise<BackofficeRole | null> => {
|
|
38
|
+
const client = await getClientsCollection().findOne(
|
|
39
|
+
{ clientId },
|
|
40
|
+
{ projection: { "roles.backoffice": 1 } },
|
|
41
|
+
);
|
|
42
|
+
const flags = client?.roles?.backoffice;
|
|
43
|
+
if (flags?.admin) return "admin";
|
|
44
|
+
if (flags?.viewer) return "viewer";
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const setBackofficeRole = async (
|
|
49
|
+
clientId: string,
|
|
50
|
+
role: BackofficeRole | null,
|
|
51
|
+
): Promise<boolean> => {
|
|
52
|
+
const { matchedCount } = await getClientsCollection().updateOne(
|
|
53
|
+
{ clientId },
|
|
54
|
+
{
|
|
55
|
+
$set: {
|
|
56
|
+
"roles.backoffice.admin": role === "admin",
|
|
57
|
+
"roles.backoffice.viewer": role === "viewer",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
return matchedCount > 0;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const countBackofficeAdmins = (): Promise<number> =>
|
|
65
|
+
getClientsCollection().countDocuments({ "roles.backoffice.admin": true });
|
|
66
|
+
|
|
67
|
+
export const findClientsPage = async ({
|
|
68
|
+
skip,
|
|
69
|
+
limit,
|
|
70
|
+
clientId,
|
|
71
|
+
clientIds,
|
|
72
|
+
excludeClientIds,
|
|
73
|
+
search,
|
|
74
|
+
}: ClientsPageParams): Promise<ClientsPage> => {
|
|
75
|
+
const conditions: Filter<Client>[] = [];
|
|
76
|
+
if (clientId) {
|
|
77
|
+
conditions.push({ clientId });
|
|
78
|
+
}
|
|
79
|
+
if (clientIds) {
|
|
80
|
+
conditions.push({ clientId: { $in: clientIds } });
|
|
81
|
+
}
|
|
82
|
+
if (excludeClientIds) {
|
|
83
|
+
conditions.push({ clientId: { $nin: excludeClientIds } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const text = search?.trim();
|
|
87
|
+
if (text) {
|
|
88
|
+
const pattern = new RegExp(escapeRegex(text), "i");
|
|
89
|
+
const clientIdsByPhone = await getPhoneNumbersCollection().distinct("client_id", {
|
|
90
|
+
phone_number: pattern,
|
|
91
|
+
});
|
|
92
|
+
conditions.push({
|
|
93
|
+
$or: [{ clientId: pattern }, { clientId: { $in: clientIdsByPhone } }],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const filter: Filter<Client> =
|
|
98
|
+
conditions.length > 0 ? { $and: conditions } : {};
|
|
99
|
+
const collection = getClientsCollection();
|
|
100
|
+
const [clients, total] = await Promise.all([
|
|
101
|
+
applyQueryOptions(
|
|
102
|
+
collection.find<ClientsPageRow>(filter, { projection: { clientId: 1, credits: 1 } }),
|
|
103
|
+
{ sort: { createdAt: -1, _id: -1 }, skip, limit },
|
|
104
|
+
).toArray(),
|
|
105
|
+
collection.countDocuments(filter),
|
|
106
|
+
]);
|
|
107
|
+
return { clients, total };
|
|
108
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Db } from "mongodb";
|
|
2
|
+
import { getDb, insertAuditLog } from "../index";
|
|
3
|
+
import type { AuditLogEntry } from "../auditLogs/auditLogs.types";
|
|
4
|
+
import { getDb as getMunicipalDataDb } from "../../municipal";
|
|
5
|
+
import { DELETE_DENYLIST } from "./clients.constants";
|
|
6
|
+
import {
|
|
7
|
+
AuditLogResult,
|
|
8
|
+
CollectionMatch,
|
|
9
|
+
CollectionDeleteResult,
|
|
10
|
+
DeleteClientDataSummary,
|
|
11
|
+
} from "./clients.types";
|
|
12
|
+
|
|
13
|
+
// Skips websitalk on purpose — see the warning on `deleteClientDataAcrossDomains` below.
|
|
14
|
+
const findClientMatchInDb = async (
|
|
15
|
+
db: Db,
|
|
16
|
+
clientId: string,
|
|
17
|
+
): Promise<CollectionMatch[]> => {
|
|
18
|
+
const collections = await db.listCollections().toArray();
|
|
19
|
+
return Promise.all(
|
|
20
|
+
collections
|
|
21
|
+
.filter((c) => !DELETE_DENYLIST.includes(c.name))
|
|
22
|
+
.map(async (c) => {
|
|
23
|
+
const doc = await db
|
|
24
|
+
.collection(c.name)
|
|
25
|
+
.findOne({ $or: [{ clientId }, { client_id: clientId }] });
|
|
26
|
+
return { collectionName: c.name, matched: !!doc };
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const clientExistsInAnyCollection = async (
|
|
32
|
+
clientId: string,
|
|
33
|
+
): Promise<boolean> => {
|
|
34
|
+
const [talkpilotResults, municipalResults] = await Promise.all([
|
|
35
|
+
findClientMatchInDb(getDb(), clientId),
|
|
36
|
+
findClientMatchInDb(getMunicipalDataDb(), clientId),
|
|
37
|
+
]);
|
|
38
|
+
return [...talkpilotResults, ...municipalResults].some((r) => r.matched);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const deleteClientFromDb = async (
|
|
42
|
+
db: Db,
|
|
43
|
+
clientId: string,
|
|
44
|
+
): Promise<CollectionDeleteResult[]> => {
|
|
45
|
+
const collections = await db.listCollections().toArray();
|
|
46
|
+
return Promise.all(
|
|
47
|
+
collections
|
|
48
|
+
.filter((c) => !DELETE_DENYLIST.includes(c.name))
|
|
49
|
+
.map(async (c) => {
|
|
50
|
+
try {
|
|
51
|
+
const result = await db
|
|
52
|
+
.collection(c.name)
|
|
53
|
+
.deleteMany({ $or: [{ clientId }, { client_id: clientId }] });
|
|
54
|
+
return {
|
|
55
|
+
collectionName: c.name,
|
|
56
|
+
deletedCount: result.deletedCount,
|
|
57
|
+
success: true,
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
collectionName: c.name,
|
|
62
|
+
deletedCount: 0,
|
|
63
|
+
success: false,
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The data is already deleted at this point — a failed audit write must not abort the caller.
|
|
72
|
+
const tryInsertAuditLog = async (entry: AuditLogEntry): Promise<AuditLogResult> => {
|
|
73
|
+
try {
|
|
74
|
+
await insertAuditLog(entry);
|
|
75
|
+
return { auditLogged: true };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
auditLogged: false,
|
|
79
|
+
auditError: error instanceof Error ? error.message : String(error),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Deletes a client's data across talkpilot/municipal collections and logs the action.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ Does NOT delete website-talk data — `scans`/`websiteUrls` are the lookup key an
|
|
88
|
+
* external API needs to clean up that domain. Callers must invoke that API separately.
|
|
89
|
+
*/
|
|
90
|
+
export const deleteClientDataAcrossDomains = async (
|
|
91
|
+
clientId: string,
|
|
92
|
+
adminEmail: string,
|
|
93
|
+
): Promise<DeleteClientDataSummary> => {
|
|
94
|
+
const exists = await clientExistsInAnyCollection(clientId);
|
|
95
|
+
if (!exists) {
|
|
96
|
+
return {
|
|
97
|
+
clientId,
|
|
98
|
+
deleted: false,
|
|
99
|
+
reason: "Client does not exist in any collection",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [talkpilotResults, municipalResults] = await Promise.all([
|
|
104
|
+
deleteClientFromDb(getDb(), clientId),
|
|
105
|
+
deleteClientFromDb(getMunicipalDataDb(), clientId),
|
|
106
|
+
]);
|
|
107
|
+
const results = [...talkpilotResults, ...municipalResults];
|
|
108
|
+
const allSucceeded = results.every((r) => r.success);
|
|
109
|
+
|
|
110
|
+
const auditResult = await tryInsertAuditLog({
|
|
111
|
+
clientId,
|
|
112
|
+
adminEmail,
|
|
113
|
+
action: "delete",
|
|
114
|
+
timestamp: new Date(),
|
|
115
|
+
allSucceeded,
|
|
116
|
+
...(allSucceeded
|
|
117
|
+
? {}
|
|
118
|
+
: {
|
|
119
|
+
failedCollections: results
|
|
120
|
+
.filter((r) => !r.success)
|
|
121
|
+
.map((r) => r.collectionName),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
clientId,
|
|
127
|
+
deleted: true,
|
|
128
|
+
results,
|
|
129
|
+
allSucceeded,
|
|
130
|
+
...auditResult,
|
|
131
|
+
};
|
|
132
|
+
};
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { ObjectId } from "mongodb";
|
|
2
2
|
import { ClientQuotaSettings } from "./clients.quota.types";
|
|
3
|
+
import { BACKOFFICE_ROLES } from "./clients.constants";
|
|
3
4
|
|
|
4
5
|
export type { QuotaThresholdAlert, ClientQuotaSettings, QuotaAlertInput, ClientQuotaUsage, ClientQuotaState } from "./clients.quota.types";
|
|
5
6
|
|
|
7
|
+
export type BackofficeRole = (typeof BACKOFFICE_ROLES)[number];
|
|
8
|
+
|
|
9
|
+
export type BackofficeRoleFlags = {
|
|
10
|
+
admin?: boolean;
|
|
11
|
+
viewer?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ClientRoles = {
|
|
15
|
+
admin?: boolean;
|
|
16
|
+
backoffice?: BackofficeRoleFlags;
|
|
17
|
+
};
|
|
18
|
+
|
|
6
19
|
export type Client = {
|
|
7
20
|
_id?: ObjectId;
|
|
8
21
|
createdAt: Date;
|
|
@@ -13,6 +26,51 @@ export type Client = {
|
|
|
13
26
|
credits: number;
|
|
14
27
|
managedBy?: string;
|
|
15
28
|
quota?: ClientQuotaSettings;
|
|
29
|
+
roles?: ClientRoles;
|
|
16
30
|
};
|
|
17
31
|
|
|
18
32
|
export type ClientDoc = Client & { _id: ObjectId };
|
|
33
|
+
|
|
34
|
+
export type ClientsPageParams = {
|
|
35
|
+
skip: number;
|
|
36
|
+
limit: number;
|
|
37
|
+
/** Exact clientId match. */
|
|
38
|
+
clientId?: string;
|
|
39
|
+
/** Only these clients, e.g. the ones with calls in a date range. */
|
|
40
|
+
clientIds?: string[];
|
|
41
|
+
/** Every client except these, e.g. the ones that called since an inactivity cutoff. */
|
|
42
|
+
excludeClientIds?: string[];
|
|
43
|
+
/** Case-insensitive "contains" on the clientId or on any of the client's phone numbers. */
|
|
44
|
+
search?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ClientsPageRow = Pick<ClientDoc, "_id" | "clientId" | "credits">;
|
|
48
|
+
|
|
49
|
+
export type ClientsPage = {
|
|
50
|
+
clients: ClientsPageRow[];
|
|
51
|
+
total: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CollectionMatch = { collectionName: string; matched: boolean };
|
|
55
|
+
|
|
56
|
+
export type CollectionDeleteResult = {
|
|
57
|
+
collectionName: string;
|
|
58
|
+
deletedCount: number;
|
|
59
|
+
success: boolean;
|
|
60
|
+
error?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type AuditLogResult = {
|
|
64
|
+
auditLogged: boolean;
|
|
65
|
+
auditError?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Discriminated union: `deleted` tells you which shape you got without guessing. */
|
|
69
|
+
export type DeleteClientDataSummary =
|
|
70
|
+
| { clientId: string; deleted: false; reason: string }
|
|
71
|
+
| ({
|
|
72
|
+
clientId: string;
|
|
73
|
+
deleted: true;
|
|
74
|
+
results: CollectionDeleteResult[];
|
|
75
|
+
allSucceeded: boolean;
|
|
76
|
+
} & AuditLogResult);
|
|
@@ -6,9 +6,6 @@ import {
|
|
|
6
6
|
setContextNoteEntries,
|
|
7
7
|
setContextNoteConfig,
|
|
8
8
|
deleteContextNoteEntry,
|
|
9
|
-
appendContextNoteEntry,
|
|
10
|
-
updateContextNoteEntryFields,
|
|
11
|
-
pullContextNoteEntry,
|
|
12
9
|
} from "../contextNotes.getters";
|
|
13
10
|
import { ObjectId } from "../../index";
|
|
14
11
|
import {
|
|
@@ -206,144 +203,6 @@ describe("contextNotes getters", () => {
|
|
|
206
203
|
expect(saved?.notes[0]._id.toString()).toBe(noteToKeep._id.toString());
|
|
207
204
|
});
|
|
208
205
|
|
|
209
|
-
it("should append one note without replacing the others", async () => {
|
|
210
|
-
const kept = createContextNoteEntry({ title: "kept" });
|
|
211
|
-
const doc = buildContextNote({ notes: [kept] });
|
|
212
|
-
await createContextNote(doc);
|
|
213
|
-
const now = new Date("2026-10-18T12:00:00.000Z");
|
|
214
|
-
|
|
215
|
-
const added = await appendContextNoteEntry(
|
|
216
|
-
doc.clientId,
|
|
217
|
-
doc.product,
|
|
218
|
-
{
|
|
219
|
-
title: "added",
|
|
220
|
-
content: "new content",
|
|
221
|
-
activeFrom: now,
|
|
222
|
-
expiresAt: null,
|
|
223
|
-
},
|
|
224
|
-
now,
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const saved = await getContextNotesCollection().findOne({
|
|
228
|
-
clientId: doc.clientId,
|
|
229
|
-
product: doc.product,
|
|
230
|
-
});
|
|
231
|
-
expect(saved?.notes).toHaveLength(2);
|
|
232
|
-
expect(saved?.notes[0]._id.toString()).toBe(kept._id.toString());
|
|
233
|
-
expect(saved?.notes[1].title).toBe("added");
|
|
234
|
-
expect(added._id.toString()).toBe(saved?.notes[1]._id.toString());
|
|
235
|
-
expect(added.createdAt).toEqual(now);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("should create the document when appending the first note", async () => {
|
|
239
|
-
const clientId = new ObjectId().toString();
|
|
240
|
-
const now = new Date("2026-10-18T12:00:00.000Z");
|
|
241
|
-
|
|
242
|
-
const added = await appendContextNoteEntry(
|
|
243
|
-
clientId,
|
|
244
|
-
"Municipal",
|
|
245
|
-
{
|
|
246
|
-
title: "first",
|
|
247
|
-
content: "content",
|
|
248
|
-
activeFrom: now,
|
|
249
|
-
expiresAt: null,
|
|
250
|
-
},
|
|
251
|
-
now,
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
const saved = await getContextNotes(clientId, "Municipal");
|
|
255
|
-
expect(saved).toHaveLength(1);
|
|
256
|
-
expect(saved[0].notes).toHaveLength(1);
|
|
257
|
-
expect(saved[0].notes[0]._id.toString()).toBe(added._id.toString());
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("should update one note and leave the others unchanged", async () => {
|
|
261
|
-
const kept = createContextNoteEntry({ title: "kept" });
|
|
262
|
-
const target = createContextNoteEntry({ title: "before" });
|
|
263
|
-
const doc = buildContextNote({ notes: [kept, target] });
|
|
264
|
-
await createContextNote(doc);
|
|
265
|
-
const now = new Date("2026-10-18T13:00:00.000Z");
|
|
266
|
-
const activeFrom = new Date("2026-10-01T00:00:00.000Z");
|
|
267
|
-
|
|
268
|
-
const updated = await updateContextNoteEntryFields(
|
|
269
|
-
doc.clientId,
|
|
270
|
-
doc.product,
|
|
271
|
-
target._id,
|
|
272
|
-
{
|
|
273
|
-
title: "after",
|
|
274
|
-
content: "changed",
|
|
275
|
-
activeFrom,
|
|
276
|
-
expiresAt: null,
|
|
277
|
-
},
|
|
278
|
-
now,
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
const saved = await getContextNotesCollection().findOne({
|
|
282
|
-
clientId: doc.clientId,
|
|
283
|
-
product: doc.product,
|
|
284
|
-
});
|
|
285
|
-
expect(updated?.title).toBe("after");
|
|
286
|
-
expect(updated?.updatedAt).toEqual(now);
|
|
287
|
-
expect(saved?.notes).toHaveLength(2);
|
|
288
|
-
expect(saved?.notes[0].title).toBe("kept");
|
|
289
|
-
expect(saved?.notes[1].title).toBe("after");
|
|
290
|
-
expect(saved?.notes[1].content).toBe("changed");
|
|
291
|
-
expect(saved?.notes[1].createdAt).toEqual(target.createdAt);
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("should return null when updating a missing note", async () => {
|
|
295
|
-
const doc = buildContextNote({ notes: [createContextNoteEntry()] });
|
|
296
|
-
await createContextNote(doc);
|
|
297
|
-
|
|
298
|
-
const updated = await updateContextNoteEntryFields(
|
|
299
|
-
doc.clientId,
|
|
300
|
-
doc.product,
|
|
301
|
-
new ObjectId(),
|
|
302
|
-
{
|
|
303
|
-
title: "missing",
|
|
304
|
-
content: "missing",
|
|
305
|
-
activeFrom: new Date(),
|
|
306
|
-
expiresAt: null,
|
|
307
|
-
},
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
expect(updated).toBeNull();
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it("should pull one note and leave the others", async () => {
|
|
314
|
-
const kept = createContextNoteEntry();
|
|
315
|
-
const removed = createContextNoteEntry();
|
|
316
|
-
const doc = buildContextNote({ notes: [kept, removed] });
|
|
317
|
-
await createContextNote(doc);
|
|
318
|
-
|
|
319
|
-
const pulled = await pullContextNoteEntry(
|
|
320
|
-
doc.clientId,
|
|
321
|
-
doc.product,
|
|
322
|
-
removed._id,
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
const saved = await getContextNotesCollection().findOne({
|
|
326
|
-
clientId: doc.clientId,
|
|
327
|
-
product: doc.product,
|
|
328
|
-
});
|
|
329
|
-
expect(pulled).toBe(true);
|
|
330
|
-
expect(saved?.notes).toHaveLength(1);
|
|
331
|
-
expect(saved?.notes[0]._id.toString()).toBe(kept._id.toString());
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it("should return false when pulling a missing note", async () => {
|
|
335
|
-
const doc = buildContextNote({ notes: [createContextNoteEntry()] });
|
|
336
|
-
await createContextNote(doc);
|
|
337
|
-
|
|
338
|
-
const pulled = await pullContextNoteEntry(
|
|
339
|
-
doc.clientId,
|
|
340
|
-
doc.product,
|
|
341
|
-
new ObjectId(),
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
expect(pulled).toBe(false);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
206
|
it("should return false when deleteContextNoteEntry targets a non-existent note", async () => {
|
|
348
207
|
const doc = buildContextNote({ notes: [] });
|
|
349
208
|
const id = await createContextNote(doc);
|