@talkpilot/core-db 1.2.1 → 1.2.2
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/.cursor/rules/development.mdc +65 -65
- package/DEVELOPMENT.md +98 -98
- package/README.md +139 -160
- package/README_OLD.md +160 -0
- package/dist/talkpilot/calls/calls.dashboard.d.ts +3 -0
- package/dist/talkpilot/calls/calls.dashboard.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.dashboard.js +191 -0
- package/dist/talkpilot/calls/calls.dashboard.js.map +1 -0
- package/dist/talkpilot/calls/calls.getters.d.ts +3 -2
- package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.getters.js +1 -2
- package/dist/talkpilot/calls/calls.getters.js.map +1 -1
- package/dist/talkpilot/calls/calls.types.d.ts +3 -7
- package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +36 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js +208 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +66 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js +3 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js.map +1 -0
- package/dist/talkpilot/calls/index.d.ts +1 -0
- package/dist/talkpilot/calls/index.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.js +1 -0
- package/dist/talkpilot/calls/index.js.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts +2 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.js +14 -0
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.js.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts +4 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.types.js +6 -0
- package/dist/talkpilot/clientsConfig/clientsConfig.types.js.map +1 -1
- package/dist/talkpilot/flows/flows.schema.js +1 -1
- package/dist/talkpilot/phone_numbers/index.d.ts +2 -2
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts.map +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.js +5 -3
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.js.map +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.schema.js +12 -12
- package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts +4 -4
- package/dist/talkpilot/results/results.getter.d.ts.map +1 -1
- package/dist/talkpilot/results/results.getter.js.map +1 -1
- package/dist/talkpilot/retry_analyze/retryAnalyze.getters.d.ts.map +1 -1
- package/dist/talkpilot/retry_analyze/retryAnalyze.getters.js.map +1 -1
- package/dist/utils/shared.types.d.ts +5 -0
- package/dist/utils/shared.types.d.ts.map +1 -0
- package/dist/utils/shared.types.js +3 -0
- package/dist/utils/shared.types.js.map +1 -0
- package/jest.config.js +19 -19
- package/package.json +46 -45
- package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +46 -0
- package/src/talkpilot/calls/__tests__/calls.spec.ts +48 -30
- package/src/talkpilot/calls/calls.getters.ts +6 -6
- package/src/talkpilot/calls/calls.types.ts +10 -12
- package/src/talkpilot/calls/dashboard/calls.dashboard.ts +243 -0
- package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +70 -0
- package/src/talkpilot/calls/index.ts +1 -0
- package/src/talkpilot/clientsConfig/__tests__/clientsConfig.getters.spec.ts +53 -0
- package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +19 -9
- package/src/talkpilot/clientsConfig/clientsConfig.getters.ts +34 -1
- package/src/talkpilot/clientsConfig/clientsConfig.types.ts +9 -1
- package/src/talkpilot/flows/__tests__/flows.schema.spec.ts +6 -2
- package/src/talkpilot/flows/flows.schema.ts +1 -1
- package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +40 -35
- package/src/talkpilot/phone_numbers/index.ts +2 -2
- package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +10 -6
- package/src/talkpilot/phone_numbers/phone_numbers.schema.ts +12 -12
- package/src/talkpilot/phone_numbers/phone_numbers.types.ts +4 -4
- package/src/talkpilot/results/results.getter.ts +6 -2
- package/src/talkpilot/retry_analyze/retryAnalyze.getters.ts +13 -4
- package/src/utils/shared.types.ts +4 -0
- package/tsconfig.json +23 -23
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.types.js","sourceRoot":"","sources":["../../src/utils/shared.types.ts"],"names":[],"mappings":""}
|
package/jest.config.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
-
module.exports = {
|
|
3
|
-
preset: 'ts-jest',
|
|
4
|
-
testEnvironment: 'node',
|
|
5
|
-
roots: ['<rootDir>/src'],
|
|
6
|
-
testPathIgnorePatterns: ['<rootDir>/src/__tests__/legacy/'],
|
|
7
|
-
testMatch: ['**/__tests__/**/*.spec.ts', '**/*.spec.ts'],
|
|
8
|
-
transform: {
|
|
9
|
-
'^.+\\.tsx?$': ['ts-jest', {
|
|
10
|
-
tsconfig: 'tsconfig.json',
|
|
11
|
-
}],
|
|
12
|
-
},
|
|
13
|
-
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
|
|
14
|
-
verbose: true,
|
|
15
|
-
forceExit: true,
|
|
16
|
-
clearMocks: true,
|
|
17
|
-
resetMocks: true,
|
|
18
|
-
restoreMocks: true,
|
|
19
|
-
};
|
|
1
|
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
preset: 'ts-jest',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
roots: ['<rootDir>/src'],
|
|
6
|
+
testPathIgnorePatterns: ['<rootDir>/src/__tests__/legacy/'],
|
|
7
|
+
testMatch: ['**/__tests__/**/*.spec.ts', '**/*.spec.ts'],
|
|
8
|
+
transform: {
|
|
9
|
+
'^.+\\.tsx?$': ['ts-jest', {
|
|
10
|
+
tsconfig: 'tsconfig.json',
|
|
11
|
+
}],
|
|
12
|
+
},
|
|
13
|
+
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
|
|
14
|
+
verbose: true,
|
|
15
|
+
forceExit: true,
|
|
16
|
+
clearMocks: true,
|
|
17
|
+
resetMocks: true,
|
|
18
|
+
restoreMocks: true,
|
|
19
|
+
};
|
package/package.json
CHANGED
|
@@ -1,45 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@talkpilot/core-db",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "Core database package for centralized connections and ORM integration.",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"types": "dist/index.d.ts",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"build": "tsc",
|
|
9
|
-
"dev": "tsc --watch",
|
|
10
|
-
"test": "jest",
|
|
11
|
-
"lint": "eslint 'src/**/*.{ts,tsx}'",
|
|
12
|
-
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
13
|
-
"prepare": "npm run build"
|
|
14
|
-
},
|
|
15
|
-
"keywords": [
|
|
16
|
-
"database",
|
|
17
|
-
"orm",
|
|
18
|
-
"typescript",
|
|
19
|
-
"postgres",
|
|
20
|
-
"db-client"
|
|
21
|
-
],
|
|
22
|
-
"author": "",
|
|
23
|
-
"license": "MIT",
|
|
24
|
-
"dependencies": {
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"@
|
|
32
|
-
"@types/
|
|
33
|
-
"@types/
|
|
34
|
-
"@types/
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@talkpilot/core-db",
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"description": "Core database package for centralized connections and ORM integration.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch",
|
|
10
|
+
"test": "jest",
|
|
11
|
+
"lint": "eslint 'src/**/*.{ts,tsx}'",
|
|
12
|
+
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
13
|
+
"prepare": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"database",
|
|
17
|
+
"orm",
|
|
18
|
+
"typescript",
|
|
19
|
+
"postgres",
|
|
20
|
+
"db-client"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"dayjs": "^1.11.21",
|
|
26
|
+
"express": "^4.18.0",
|
|
27
|
+
"google-libphonenumber": "^3.2.0",
|
|
28
|
+
"mongodb": "^6.11.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@faker-js/faker": "^8.0.0",
|
|
32
|
+
"@types/express": "^4.17.0",
|
|
33
|
+
"@types/google-libphonenumber": "^7.4.0",
|
|
34
|
+
"@types/jest": "^29.0.0",
|
|
35
|
+
"@types/node": "^20.0.0",
|
|
36
|
+
"fishery": "^2.4.0",
|
|
37
|
+
"jest": "^29.0.0",
|
|
38
|
+
"mongodb-memory-server": "^10.0.0",
|
|
39
|
+
"prettier": "^3.8.2",
|
|
40
|
+
"ts-jest": "^29.0.0",
|
|
41
|
+
"typescript": "^5.0.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
|
|
2
|
+
import { getDashboardStats } from "../dashboard/calls.dashboard";
|
|
3
|
+
import { getCallsCollection } from "../calls.getters";
|
|
4
|
+
import { getClientsConfigCollection } from "../../clientsConfig/clientsConfig.getters";
|
|
5
|
+
|
|
6
|
+
describe("getDashboardStats", () => {
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
await getCallsCollection().deleteMany({});
|
|
9
|
+
await getClientsConfigCollection().deleteMany({});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await getCallsCollection().deleteMany({});
|
|
14
|
+
await getClientsConfigCollection().deleteMany({});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should return correct empty dashboard stats when no calls exist", async () => {
|
|
18
|
+
const clientId = "client-dash-123";
|
|
19
|
+
|
|
20
|
+
await getClientsConfigCollection().insertOne({
|
|
21
|
+
clientId,
|
|
22
|
+
timezone: "UTC",
|
|
23
|
+
products: {},
|
|
24
|
+
} as any);
|
|
25
|
+
|
|
26
|
+
const params = {
|
|
27
|
+
clientId,
|
|
28
|
+
startDate: "2026-05-01",
|
|
29
|
+
endDate: "2026-05-31",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const result = await getDashboardStats(params);
|
|
33
|
+
|
|
34
|
+
expect(result).toBeDefined();
|
|
35
|
+
expect(result.kpis.totalCalls).toBe(0);
|
|
36
|
+
expect(result.kpis.completedCount).toBe(0);
|
|
37
|
+
expect(result.kpis.avgDurationSeconds).toBe(0);
|
|
38
|
+
expect(result.charts.volumeData).toEqual([]);
|
|
39
|
+
expect(result.charts.heatmap).toEqual({});
|
|
40
|
+
expect(result.charts.callLengthBuckets).toEqual({
|
|
41
|
+
short: 0,
|
|
42
|
+
medium: 0,
|
|
43
|
+
long: 0,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
getCallsCollection,
|
|
11
11
|
pushToolExecution,
|
|
12
12
|
} from "../calls.getters";
|
|
13
|
-
import type { ToolExecution } from
|
|
13
|
+
import type { ToolExecution } from "../calls.types";
|
|
14
14
|
import { ObjectId } from "mongodb";
|
|
15
15
|
import { createOutGoingCallDoc } from "../../../test-utils/factories";
|
|
16
16
|
|
|
@@ -115,7 +115,7 @@ describe("db.calls", () => {
|
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
describe(
|
|
118
|
+
describe("pushToolExecution()", () => {
|
|
119
119
|
let callSid: string;
|
|
120
120
|
|
|
121
121
|
beforeEach(async () => {
|
|
@@ -124,63 +124,73 @@ describe("db.calls", () => {
|
|
|
124
124
|
callSid = call.callSid;
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
const makeHttpExecution = (
|
|
128
|
-
|
|
127
|
+
const makeHttpExecution = (
|
|
128
|
+
overrides?: Partial<ToolExecution>,
|
|
129
|
+
): ToolExecution => ({
|
|
130
|
+
toolName: "sendSms",
|
|
129
131
|
executedAt: new Date(),
|
|
130
132
|
durationMs: 120,
|
|
131
|
-
args: { to:
|
|
132
|
-
meta: {
|
|
133
|
-
|
|
133
|
+
args: { to: "+1234567890", message: "hello" },
|
|
134
|
+
meta: {
|
|
135
|
+
kind: "http",
|
|
136
|
+
url: "https://api.example.com/sms",
|
|
137
|
+
method: "POST",
|
|
138
|
+
},
|
|
139
|
+
status: "success",
|
|
134
140
|
httpStatus: 200,
|
|
135
141
|
...overrides,
|
|
136
142
|
});
|
|
137
143
|
|
|
138
|
-
it(
|
|
144
|
+
it("should append an execution to a call with none", async () => {
|
|
139
145
|
const execution = makeHttpExecution();
|
|
140
146
|
await pushToolExecution(callSid, execution);
|
|
141
147
|
|
|
142
148
|
const result = await getCallByCallSid(callSid);
|
|
143
149
|
expect(result?.toolExecutions).toHaveLength(1);
|
|
144
150
|
expect(result?.toolExecutions?.[0]).toMatchObject({
|
|
145
|
-
toolName:
|
|
146
|
-
status:
|
|
151
|
+
toolName: "sendSms",
|
|
152
|
+
status: "success",
|
|
147
153
|
httpStatus: 200,
|
|
148
154
|
});
|
|
149
155
|
});
|
|
150
156
|
|
|
151
|
-
it(
|
|
152
|
-
const first = makeHttpExecution({ toolName:
|
|
153
|
-
const second = makeHttpExecution({ toolName:
|
|
154
|
-
const third = makeHttpExecution({ toolName:
|
|
157
|
+
it("should maintain insertion order across multiple pushes", async () => {
|
|
158
|
+
const first = makeHttpExecution({ toolName: "first" });
|
|
159
|
+
const second = makeHttpExecution({ toolName: "second" });
|
|
160
|
+
const third = makeHttpExecution({ toolName: "third" });
|
|
155
161
|
|
|
156
162
|
await pushToolExecution(callSid, first);
|
|
157
163
|
await pushToolExecution(callSid, second);
|
|
158
164
|
await pushToolExecution(callSid, third);
|
|
159
165
|
|
|
160
166
|
const result = await getCallByCallSid(callSid);
|
|
161
|
-
expect(result?.toolExecutions?.map(e => e.toolName)).toEqual([
|
|
167
|
+
expect(result?.toolExecutions?.map((e) => e.toolName)).toEqual([
|
|
168
|
+
"first",
|
|
169
|
+
"second",
|
|
170
|
+
"third",
|
|
171
|
+
]);
|
|
162
172
|
});
|
|
163
173
|
|
|
164
|
-
it(
|
|
174
|
+
it("should store an internal tool execution", async () => {
|
|
165
175
|
const execution: ToolExecution = {
|
|
166
|
-
toolName:
|
|
176
|
+
toolName: "endFlow",
|
|
167
177
|
executedAt: new Date(),
|
|
168
178
|
durationMs: 5,
|
|
169
179
|
args: {},
|
|
170
|
-
meta: { kind:
|
|
171
|
-
status:
|
|
180
|
+
meta: { kind: "internal" },
|
|
181
|
+
status: "success",
|
|
172
182
|
};
|
|
173
183
|
|
|
174
184
|
await pushToolExecution(callSid, execution);
|
|
175
185
|
|
|
176
186
|
const result = await getCallByCallSid(callSid);
|
|
177
187
|
expect(result?.toolExecutions?.[0]).toMatchObject({
|
|
178
|
-
toolName:
|
|
179
|
-
meta: { kind:
|
|
188
|
+
toolName: "endFlow",
|
|
189
|
+
meta: { kind: "internal" },
|
|
180
190
|
});
|
|
181
191
|
});
|
|
182
192
|
|
|
183
|
-
it(
|
|
193
|
+
it("should store redacted args for sensitive tools", async () => {
|
|
184
194
|
const execution = makeHttpExecution({ args: { _redacted: true } });
|
|
185
195
|
await pushToolExecution(callSid, execution);
|
|
186
196
|
|
|
@@ -188,26 +198,34 @@ describe("db.calls", () => {
|
|
|
188
198
|
expect(result?.toolExecutions?.[0].args).toEqual({ _redacted: true });
|
|
189
199
|
});
|
|
190
200
|
|
|
191
|
-
it(
|
|
192
|
-
const execution = makeHttpExecution({ status:
|
|
201
|
+
it("should store an error execution", async () => {
|
|
202
|
+
const execution = makeHttpExecution({ status: "error", httpStatus: 500 });
|
|
193
203
|
await pushToolExecution(callSid, execution);
|
|
194
204
|
|
|
195
205
|
const result = await getCallByCallSid(callSid);
|
|
196
|
-
expect(result?.toolExecutions?.[0]).toMatchObject({
|
|
206
|
+
expect(result?.toolExecutions?.[0]).toMatchObject({
|
|
207
|
+
status: "error",
|
|
208
|
+
httpStatus: 500,
|
|
209
|
+
});
|
|
197
210
|
});
|
|
198
211
|
|
|
199
|
-
it(
|
|
200
|
-
await expect(
|
|
212
|
+
it("should be a no-op for an unknown callSid", async () => {
|
|
213
|
+
await expect(
|
|
214
|
+
pushToolExecution("nonexistent-sid", makeHttpExecution()),
|
|
215
|
+
).resolves.not.toThrow();
|
|
201
216
|
});
|
|
202
217
|
|
|
203
|
-
it(
|
|
218
|
+
it("should store the response body", async () => {
|
|
204
219
|
const execution = makeHttpExecution({
|
|
205
|
-
response: { userId:
|
|
220
|
+
response: { userId: "123", status: "sent" },
|
|
206
221
|
});
|
|
207
222
|
await pushToolExecution(callSid, execution);
|
|
208
223
|
|
|
209
224
|
const result = await getCallByCallSid(callSid);
|
|
210
|
-
expect(result?.toolExecutions?.[0].response).toEqual({
|
|
225
|
+
expect(result?.toolExecutions?.[0].response).toEqual({
|
|
226
|
+
userId: "123",
|
|
227
|
+
status: "sent",
|
|
228
|
+
});
|
|
211
229
|
});
|
|
212
230
|
});
|
|
213
231
|
|
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
CallUpdateParams,
|
|
6
6
|
getDb,
|
|
7
7
|
} from "../index";
|
|
8
|
-
import type {
|
|
8
|
+
import type { CountOpts, DateRange, ToolExecution } from "./calls.types";
|
|
9
9
|
import { Filter, ObjectId } from "mongodb";
|
|
10
10
|
import * as process from "node:process";
|
|
11
11
|
import { applyQueryOptions } from "../utils/query.utils";
|
|
12
|
+
import { DashboardHeatmapMetric } from "./dashboard/calls.dashboard.types";
|
|
12
13
|
|
|
13
14
|
export const getCallsCollection = () => {
|
|
14
15
|
return getDb().collection<Call>("calls");
|
|
@@ -61,11 +62,11 @@ export const updateCallByCallSid = async (
|
|
|
61
62
|
|
|
62
63
|
export const pushToolExecution = async (
|
|
63
64
|
callSid: string,
|
|
64
|
-
execution: ToolExecution
|
|
65
|
+
execution: ToolExecution,
|
|
65
66
|
): Promise<void> => {
|
|
66
67
|
await getCallsCollection().updateOne(
|
|
67
68
|
{ callSid },
|
|
68
|
-
{ $push: { toolExecutions: execution } }
|
|
69
|
+
{ $push: { toolExecutions: execution } },
|
|
69
70
|
);
|
|
70
71
|
};
|
|
71
72
|
|
|
@@ -217,13 +218,12 @@ export async function getCallsHourlyAggregation(
|
|
|
217
218
|
clientId: string,
|
|
218
219
|
dateStr: string,
|
|
219
220
|
timezone: string,
|
|
220
|
-
): Promise<
|
|
221
|
+
): Promise<DashboardHeatmapMetric[]> {
|
|
221
222
|
const coll = getCallsCollection();
|
|
222
223
|
const rows = await coll
|
|
223
224
|
.aggregate<{ _id: number; calls: number }>([
|
|
224
225
|
// 1. Restrict to the given client
|
|
225
226
|
{ $match: { clientId } },
|
|
226
|
-
// 2. Derive dateLocal (createdAt as YYYY-MM-DD in timezone) and hour (0–23 from createdAt in timezone)
|
|
227
227
|
{
|
|
228
228
|
$addFields: {
|
|
229
229
|
dateLocal: {
|
|
@@ -242,7 +242,7 @@ export async function getCallsHourlyAggregation(
|
|
|
242
242
|
.toArray();
|
|
243
243
|
|
|
244
244
|
return rows.map((r: any) => ({
|
|
245
|
-
hour:
|
|
245
|
+
hour: r._id,
|
|
246
246
|
calls: r.calls,
|
|
247
247
|
}));
|
|
248
248
|
}
|
|
@@ -47,21 +47,21 @@ export type ToolExecution = {
|
|
|
47
47
|
durationMs: number;
|
|
48
48
|
args: Record<string, unknown> | { _redacted: true };
|
|
49
49
|
meta: ToolExecutionMeta;
|
|
50
|
-
status:
|
|
50
|
+
status: "success" | "error";
|
|
51
51
|
httpStatus?: number;
|
|
52
52
|
response?: Record<string, unknown>;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
export type ToolExecutionMeta =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
56
|
+
| {
|
|
57
|
+
kind: "http";
|
|
58
|
+
url: string;
|
|
59
|
+
method: string;
|
|
60
|
+
flowToolId?: string;
|
|
61
|
+
runInBackground?: boolean;
|
|
62
|
+
sensitive?: boolean;
|
|
63
|
+
}
|
|
64
|
+
| { kind: "internal" };
|
|
65
65
|
|
|
66
66
|
export type CallQueryOptions = {
|
|
67
67
|
sort?: Sort;
|
|
@@ -102,8 +102,6 @@ export type CallUpdateParams = Partial<Omit<Call, ImmutableCallFields>>;
|
|
|
102
102
|
|
|
103
103
|
export type CallDoc = WithId<Call>;
|
|
104
104
|
|
|
105
|
-
export type CallsByHour = { hour: string; calls: number };
|
|
106
|
-
|
|
107
105
|
export type CountOpts = {
|
|
108
106
|
isOutgoingCall?: boolean;
|
|
109
107
|
isIncomingCall?: boolean;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import dayjs from "dayjs";
|
|
2
|
+
import { getCallsCollection } from "../calls.getters";
|
|
3
|
+
import { getClientConfig } from "../../clientsConfig";
|
|
4
|
+
import utc from "dayjs/plugin/utc";
|
|
5
|
+
import timezonePlugin from "dayjs/plugin/timezone";
|
|
6
|
+
import {
|
|
7
|
+
DashboardAggregationResult,
|
|
8
|
+
DashboardDailyTrendMetric,
|
|
9
|
+
DashboardHeatmapMetric,
|
|
10
|
+
DashboardReportQuery,
|
|
11
|
+
DashboardReportResponse,
|
|
12
|
+
DashboardSummaryMetrics,
|
|
13
|
+
RawDailyAggregationResult,
|
|
14
|
+
RawHourlyAggregationResult,
|
|
15
|
+
} from "./calls.dashboard.types";
|
|
16
|
+
import { CallLengthThresholds } from "src/utils/shared.types";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_KPI_DATA = {
|
|
19
|
+
totalCalls: 0,
|
|
20
|
+
totalDuration: 0,
|
|
21
|
+
completedCount: 0,
|
|
22
|
+
failedCount: 0,
|
|
23
|
+
noAnswerCount: 0,
|
|
24
|
+
busyCount: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
dayjs.extend(utc);
|
|
28
|
+
dayjs.extend(timezonePlugin);
|
|
29
|
+
|
|
30
|
+
function buildKpisPipeline() {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
$group: {
|
|
34
|
+
_id: null,
|
|
35
|
+
totalCalls: { $sum: 1 },
|
|
36
|
+
totalDuration: { $sum: "$callLength" },
|
|
37
|
+
completedCount: {
|
|
38
|
+
$sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
|
|
39
|
+
},
|
|
40
|
+
failedCount: {
|
|
41
|
+
$sum: { $cond: [{ $eq: ["$status", "failed"] }, 1, 0] },
|
|
42
|
+
},
|
|
43
|
+
noAnswerCount: {
|
|
44
|
+
$sum: { $cond: [{ $eq: ["$status", "no-answer"] }, 1, 0] },
|
|
45
|
+
},
|
|
46
|
+
busyCount: {
|
|
47
|
+
$sum: { $cond: [{ $eq: ["$status", "busy"] }, 1, 0] },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildDailyDataPipeline(timezone: string) {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
$group: {
|
|
58
|
+
_id: {
|
|
59
|
+
$dateToString: {
|
|
60
|
+
format: "%Y-%m-%d",
|
|
61
|
+
date: "$createdAt",
|
|
62
|
+
timezone: timezone,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
count: { $sum: 1 },
|
|
66
|
+
completed: {
|
|
67
|
+
$sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ $sort: { _id: 1 } },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildHourlyDataPipeline(timezone: string) {
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
$group: {
|
|
79
|
+
_id: {
|
|
80
|
+
day: {
|
|
81
|
+
$dateToString: {
|
|
82
|
+
format: "%Y-%m-%d",
|
|
83
|
+
date: "$createdAt",
|
|
84
|
+
timezone: timezone,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
hour: { $hour: { date: "$createdAt", timezone: timezone } },
|
|
88
|
+
},
|
|
89
|
+
count: { $sum: 1 },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildCallLengthBucketsPipeline(
|
|
96
|
+
thresholds: CallLengthThresholds,
|
|
97
|
+
) {
|
|
98
|
+
const { shortThreshold, mediumThreshold } = thresholds;
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
$group: {
|
|
103
|
+
_id: null,
|
|
104
|
+
short: {
|
|
105
|
+
$sum: { $cond: [{ $lt: ["$callLength", shortThreshold] }, 1, 0] },
|
|
106
|
+
},
|
|
107
|
+
medium: {
|
|
108
|
+
$sum: {
|
|
109
|
+
$cond: [
|
|
110
|
+
{
|
|
111
|
+
$and: [
|
|
112
|
+
{ $gte: ["$callLength", shortThreshold] },
|
|
113
|
+
{ $lte: ["$callLength", mediumThreshold] },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
1,
|
|
117
|
+
0,
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
long: {
|
|
122
|
+
$sum: { $cond: [{ $gt: ["$callLength", mediumThreshold] }, 1, 0] },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildHeatmapData(
|
|
130
|
+
hourlyDataRaw: RawHourlyAggregationResult[],
|
|
131
|
+
): Record<string, DashboardHeatmapMetric[]> {
|
|
132
|
+
const heatmapMap = new Map<string, Map<number, number>>();
|
|
133
|
+
|
|
134
|
+
for (const bucket of hourlyDataRaw) {
|
|
135
|
+
const dayKey = bucket._id.day;
|
|
136
|
+
const hour = bucket._id.hour;
|
|
137
|
+
if (!heatmapMap.has(dayKey)) heatmapMap.set(dayKey, new Map());
|
|
138
|
+
heatmapMap.get(dayKey)!.set(hour, bucket.count);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toSortedBuckets = (
|
|
142
|
+
map: Map<number, number>,
|
|
143
|
+
): DashboardHeatmapMetric[] =>
|
|
144
|
+
Array.from(map.entries())
|
|
145
|
+
.sort(([a], [b]) => a - b)
|
|
146
|
+
.map(([h, c]) => ({
|
|
147
|
+
hour: h,
|
|
148
|
+
calls: c,
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const heatmap: Record<string, DashboardHeatmapMetric[]> = {};
|
|
152
|
+
for (const [day, hourMap] of Array.from(heatmapMap.entries()).sort()) {
|
|
153
|
+
heatmap[day] = toSortedBuckets(hourMap);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return heatmap;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function getDashboardStats(
|
|
160
|
+
params: DashboardReportQuery,
|
|
161
|
+
): Promise<DashboardReportResponse> {
|
|
162
|
+
const { clientId, startDate, endDate } = params;
|
|
163
|
+
const clientConfig = await getClientConfig(clientId);
|
|
164
|
+
const timezone = clientConfig?.timezone ?? "UTC";
|
|
165
|
+
|
|
166
|
+
const startDateObj = dayjs.tz(startDate, timezone).startOf("day").toDate();
|
|
167
|
+
const endDateObj = dayjs.tz(endDate, timezone).endOf("day").toDate();
|
|
168
|
+
|
|
169
|
+
const thresholds: CallLengthThresholds = {
|
|
170
|
+
shortThreshold: clientConfig?.callLengthThresholds?.shortThreshold ?? 60,
|
|
171
|
+
mediumThreshold: clientConfig?.callLengthThresholds?.mediumThreshold ?? 180,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const pipeline = [
|
|
175
|
+
{
|
|
176
|
+
$match: {
|
|
177
|
+
clientId,
|
|
178
|
+
createdAt: { $gte: startDateObj, $lte: endDateObj },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
$facet: {
|
|
183
|
+
kpis: buildKpisPipeline(),
|
|
184
|
+
dailyData: buildDailyDataPipeline(timezone),
|
|
185
|
+
hourlyData: buildHourlyDataPipeline(timezone),
|
|
186
|
+
callLengthBuckets: buildCallLengthBucketsPipeline(thresholds),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const callsCollection = getCallsCollection();
|
|
192
|
+
const [aggregatedResult] = await callsCollection
|
|
193
|
+
.aggregate<DashboardAggregationResult>(pipeline)
|
|
194
|
+
.toArray();
|
|
195
|
+
|
|
196
|
+
const kpiData = aggregatedResult?.kpis?.[0] ?? DEFAULT_KPI_DATA;
|
|
197
|
+
const dailyDataRaw = aggregatedResult?.dailyData ?? [];
|
|
198
|
+
const hourlyDataRaw = aggregatedResult?.hourlyData ?? [];
|
|
199
|
+
const callLengthRaw = aggregatedResult?.callLengthBuckets?.[0] ?? {
|
|
200
|
+
short: 0,
|
|
201
|
+
medium: 0,
|
|
202
|
+
long: 0,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const kpis: DashboardSummaryMetrics = {
|
|
206
|
+
totalCalls: kpiData.totalCalls,
|
|
207
|
+
avgDurationSeconds:
|
|
208
|
+
kpiData.totalCalls > 0
|
|
209
|
+
? Math.round((kpiData.totalDuration ?? 0) / kpiData.totalCalls)
|
|
210
|
+
: 0,
|
|
211
|
+
timeSavedMinutes: Math.round((kpiData.totalDuration ?? 0) / 60),
|
|
212
|
+
successRate:
|
|
213
|
+
kpiData.totalCalls > 0
|
|
214
|
+
? Math.round((kpiData.completedCount / kpiData.totalCalls) * 1000) / 10
|
|
215
|
+
: 0,
|
|
216
|
+
completedCount: kpiData.completedCount,
|
|
217
|
+
failedCount: kpiData.failedCount,
|
|
218
|
+
noAnswerCount: kpiData.noAnswerCount,
|
|
219
|
+
busyCount: kpiData.busyCount,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const volumeData: DashboardDailyTrendMetric[] = dailyDataRaw.map((d) => ({
|
|
223
|
+
date: d._id,
|
|
224
|
+
count: d.count,
|
|
225
|
+
completed: d.completed,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const heatmap = buildHeatmapData(hourlyDataRaw);
|
|
229
|
+
|
|
230
|
+
const response: DashboardReportResponse = {
|
|
231
|
+
kpis,
|
|
232
|
+
charts: {
|
|
233
|
+
volumeData,
|
|
234
|
+
heatmap,
|
|
235
|
+
callLengthBuckets: {
|
|
236
|
+
short: callLengthRaw.short,
|
|
237
|
+
medium: callLengthRaw.medium,
|
|
238
|
+
long: callLengthRaw.long,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
return response;
|
|
243
|
+
}
|