@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.
Files changed (74) hide show
  1. package/.cursor/rules/development.mdc +65 -65
  2. package/DEVELOPMENT.md +98 -98
  3. package/README.md +139 -160
  4. package/README_OLD.md +160 -0
  5. package/dist/talkpilot/calls/calls.dashboard.d.ts +3 -0
  6. package/dist/talkpilot/calls/calls.dashboard.d.ts.map +1 -0
  7. package/dist/talkpilot/calls/calls.dashboard.js +191 -0
  8. package/dist/talkpilot/calls/calls.dashboard.js.map +1 -0
  9. package/dist/talkpilot/calls/calls.getters.d.ts +3 -2
  10. package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
  11. package/dist/talkpilot/calls/calls.getters.js +1 -2
  12. package/dist/talkpilot/calls/calls.getters.js.map +1 -1
  13. package/dist/talkpilot/calls/calls.types.d.ts +3 -7
  14. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  15. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +36 -0
  16. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -0
  17. package/dist/talkpilot/calls/dashboard/calls.dashboard.js +208 -0
  18. package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -0
  19. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +66 -0
  20. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -0
  21. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js +3 -0
  22. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js.map +1 -0
  23. package/dist/talkpilot/calls/index.d.ts +1 -0
  24. package/dist/talkpilot/calls/index.d.ts.map +1 -1
  25. package/dist/talkpilot/calls/index.js +1 -0
  26. package/dist/talkpilot/calls/index.js.map +1 -1
  27. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts +2 -1
  28. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts.map +1 -1
  29. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js +14 -0
  30. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js.map +1 -1
  31. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts +4 -1
  32. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts.map +1 -1
  33. package/dist/talkpilot/clientsConfig/clientsConfig.types.js +6 -0
  34. package/dist/talkpilot/clientsConfig/clientsConfig.types.js.map +1 -1
  35. package/dist/talkpilot/flows/flows.schema.js +1 -1
  36. package/dist/talkpilot/phone_numbers/index.d.ts +2 -2
  37. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts +1 -1
  38. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts.map +1 -1
  39. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js +5 -3
  40. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js.map +1 -1
  41. package/dist/talkpilot/phone_numbers/phone_numbers.schema.js +12 -12
  42. package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts +4 -4
  43. package/dist/talkpilot/results/results.getter.d.ts.map +1 -1
  44. package/dist/talkpilot/results/results.getter.js.map +1 -1
  45. package/dist/talkpilot/retry_analyze/retryAnalyze.getters.d.ts.map +1 -1
  46. package/dist/talkpilot/retry_analyze/retryAnalyze.getters.js.map +1 -1
  47. package/dist/utils/shared.types.d.ts +5 -0
  48. package/dist/utils/shared.types.d.ts.map +1 -0
  49. package/dist/utils/shared.types.js +3 -0
  50. package/dist/utils/shared.types.js.map +1 -0
  51. package/jest.config.js +19 -19
  52. package/package.json +46 -45
  53. package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +46 -0
  54. package/src/talkpilot/calls/__tests__/calls.spec.ts +48 -30
  55. package/src/talkpilot/calls/calls.getters.ts +6 -6
  56. package/src/talkpilot/calls/calls.types.ts +10 -12
  57. package/src/talkpilot/calls/dashboard/calls.dashboard.ts +243 -0
  58. package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +70 -0
  59. package/src/talkpilot/calls/index.ts +1 -0
  60. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.getters.spec.ts +53 -0
  61. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +19 -9
  62. package/src/talkpilot/clientsConfig/clientsConfig.getters.ts +34 -1
  63. package/src/talkpilot/clientsConfig/clientsConfig.types.ts +9 -1
  64. package/src/talkpilot/flows/__tests__/flows.schema.spec.ts +6 -2
  65. package/src/talkpilot/flows/flows.schema.ts +1 -1
  66. package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +40 -35
  67. package/src/talkpilot/phone_numbers/index.ts +2 -2
  68. package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +10 -6
  69. package/src/talkpilot/phone_numbers/phone_numbers.schema.ts +12 -12
  70. package/src/talkpilot/phone_numbers/phone_numbers.types.ts +4 -4
  71. package/src/talkpilot/results/results.getter.ts +6 -2
  72. package/src/talkpilot/retry_analyze/retryAnalyze.getters.ts +13 -4
  73. package/src/utils/shared.types.ts +4 -0
  74. 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.1",
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
- "express": "^4.18.0",
26
- "google-libphonenumber": "^3.2.0",
27
- "mongodb": "^6.11.0"
28
- },
29
- "devDependencies": {
30
- "@faker-js/faker": "^8.0.0",
31
- "@types/express": "^4.17.0",
32
- "@types/google-libphonenumber": "^7.4.0",
33
- "@types/jest": "^29.0.0",
34
- "@types/node": "^20.0.0",
35
- "fishery": "^2.4.0",
36
- "jest": "^29.0.0",
37
- "mongodb-memory-server": "^10.0.0",
38
- "prettier": "^3.8.2",
39
- "ts-jest": "^29.0.0",
40
- "typescript": "^5.0.0"
41
- },
42
- "publishConfig": {
43
- "access": "public"
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 '../calls.types';
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('pushToolExecution()', () => {
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 = (overrides?: Partial<ToolExecution>): ToolExecution => ({
128
- toolName: 'sendSms',
127
+ const makeHttpExecution = (
128
+ overrides?: Partial<ToolExecution>,
129
+ ): ToolExecution => ({
130
+ toolName: "sendSms",
129
131
  executedAt: new Date(),
130
132
  durationMs: 120,
131
- args: { to: '+1234567890', message: 'hello' },
132
- meta: { kind: 'http', url: 'https://api.example.com/sms', method: 'POST' },
133
- status: 'success',
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('should append an execution to a call with none', async () => {
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: 'sendSms',
146
- status: 'success',
151
+ toolName: "sendSms",
152
+ status: "success",
147
153
  httpStatus: 200,
148
154
  });
149
155
  });
150
156
 
151
- it('should maintain insertion order across multiple pushes', async () => {
152
- const first = makeHttpExecution({ toolName: 'first' });
153
- const second = makeHttpExecution({ toolName: 'second' });
154
- const third = makeHttpExecution({ toolName: 'third' });
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(['first', 'second', 'third']);
167
+ expect(result?.toolExecutions?.map((e) => e.toolName)).toEqual([
168
+ "first",
169
+ "second",
170
+ "third",
171
+ ]);
162
172
  });
163
173
 
164
- it('should store an internal tool execution', async () => {
174
+ it("should store an internal tool execution", async () => {
165
175
  const execution: ToolExecution = {
166
- toolName: 'endFlow',
176
+ toolName: "endFlow",
167
177
  executedAt: new Date(),
168
178
  durationMs: 5,
169
179
  args: {},
170
- meta: { kind: 'internal' },
171
- status: 'success',
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: 'endFlow',
179
- meta: { kind: 'internal' },
188
+ toolName: "endFlow",
189
+ meta: { kind: "internal" },
180
190
  });
181
191
  });
182
192
 
183
- it('should store redacted args for sensitive tools', async () => {
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('should store an error execution', async () => {
192
- const execution = makeHttpExecution({ status: 'error', httpStatus: 500 });
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({ status: 'error', httpStatus: 500 });
206
+ expect(result?.toolExecutions?.[0]).toMatchObject({
207
+ status: "error",
208
+ httpStatus: 500,
209
+ });
197
210
  });
198
211
 
199
- it('should be a no-op for an unknown callSid', async () => {
200
- await expect(pushToolExecution('nonexistent-sid', makeHttpExecution())).resolves.not.toThrow();
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('should store the response body', async () => {
218
+ it("should store the response body", async () => {
204
219
  const execution = makeHttpExecution({
205
- response: { userId: '123', status: 'sent' },
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({ userId: '123', status: 'sent' });
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 { CallsByHour, CountOpts, DateRange, ToolExecution } from "./calls.types";
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<CallsByHour[]> {
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: `${String(r._id).padStart(2, "0")}:00`,
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: 'success' | 'error';
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
- kind: 'http';
58
- url: string;
59
- method: string;
60
- flowToolId?: string;
61
- runInBackground?: boolean;
62
- sensitive?: boolean;
63
- }
64
- | { kind: 'internal' };
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
+ }