@talkpilot/core-db 1.3.0 → 1.3.1

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 (101) hide show
  1. package/README.md +0 -30
  2. package/dist/municipal/tickets/index.d.ts +1 -2
  3. package/dist/municipal/tickets/index.d.ts.map +1 -1
  4. package/dist/municipal/tickets/index.js +0 -1
  5. package/dist/municipal/tickets/index.js.map +1 -1
  6. package/dist/municipal/tickets/tickets.getters.d.ts +11 -0
  7. package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
  8. package/dist/municipal/tickets/tickets.getters.js +128 -0
  9. package/dist/municipal/tickets/tickets.getters.js.map +1 -1
  10. package/dist/municipal/tickets/tickets.types.d.ts +5 -10
  11. package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
  12. package/dist/talkpilot/calls/calls.types.d.ts +2 -1
  13. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  14. package/dist/talkpilot/calls/calls.types.js +3 -0
  15. package/dist/talkpilot/calls/calls.types.js.map +1 -1
  16. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +1 -33
  17. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -1
  18. package/dist/talkpilot/calls/dashboard/calls.dashboard.js +146 -131
  19. package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -1
  20. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +6 -27
  21. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -1
  22. package/dist/talkpilot/calls/index.d.ts +0 -3
  23. package/dist/talkpilot/calls/index.d.ts.map +1 -1
  24. package/dist/talkpilot/calls/index.js +0 -3
  25. package/dist/talkpilot/calls/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +37 -1
  28. package/src/municipal/tickets/index.ts +1 -2
  29. package/src/municipal/tickets/tickets.getters.ts +140 -0
  30. package/src/municipal/tickets/tickets.types.ts +9 -14
  31. package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +111 -8
  32. package/src/talkpilot/calls/calls.types.ts +2 -4
  33. package/src/talkpilot/calls/dashboard/calls.dashboard.ts +197 -148
  34. package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +12 -25
  35. package/src/talkpilot/calls/index.ts +0 -3
  36. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +0 -7
  37. package/dist/municipal/tickets/tickets.constants.d.ts +0 -7
  38. package/dist/municipal/tickets/tickets.constants.d.ts.map +0 -1
  39. package/dist/municipal/tickets/tickets.constants.js +0 -10
  40. package/dist/municipal/tickets/tickets.constants.js.map +0 -1
  41. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +0 -12
  42. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +0 -1
  43. package/dist/municipal/tickets/tickets.deprecated.getters.js +0 -131
  44. package/dist/municipal/tickets/tickets.deprecated.getters.js.map +0 -1
  45. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +0 -45
  46. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +0 -1
  47. package/dist/municipal/tickets/tickets.statistics.aggregation.js +0 -98
  48. package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +0 -1
  49. package/dist/municipal/tickets/tickets.statistics.dates.d.ts +0 -7
  50. package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +0 -1
  51. package/dist/municipal/tickets/tickets.statistics.dates.js +0 -40
  52. package/dist/municipal/tickets/tickets.statistics.dates.js.map +0 -1
  53. package/dist/municipal/tickets/tickets.statistics.getters.d.ts +0 -9
  54. package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +0 -1
  55. package/dist/municipal/tickets/tickets.statistics.getters.js +0 -55
  56. package/dist/municipal/tickets/tickets.statistics.getters.js.map +0 -1
  57. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +0 -53
  58. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +0 -1
  59. package/dist/municipal/tickets/tickets.statistics.pipeline.js +0 -112
  60. package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +0 -1
  61. package/dist/municipal/tickets/tickets.statistics.utils.d.ts +0 -7
  62. package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +0 -1
  63. package/dist/municipal/tickets/tickets.statistics.utils.js +0 -40
  64. package/dist/municipal/tickets/tickets.statistics.utils.js.map +0 -1
  65. package/dist/talkpilot/calls/calls.constants.d.ts +0 -17
  66. package/dist/talkpilot/calls/calls.constants.d.ts.map +0 -1
  67. package/dist/talkpilot/calls/calls.constants.js +0 -20
  68. package/dist/talkpilot/calls/calls.constants.js.map +0 -1
  69. package/dist/talkpilot/calls/calls.statistics.getters.d.ts +0 -19
  70. package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +0 -1
  71. package/dist/talkpilot/calls/calls.statistics.getters.js +0 -375
  72. package/dist/talkpilot/calls/calls.statistics.getters.js.map +0 -1
  73. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +0 -12
  74. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +0 -1
  75. package/dist/talkpilot/calls/calls.statistics.ticketScope.js +0 -37
  76. package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +0 -1
  77. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +0 -17
  78. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +0 -1
  79. package/dist/talkpilot/calls/calls.statistics.tickets.js +0 -33
  80. package/dist/talkpilot/calls/calls.statistics.tickets.js.map +0 -1
  81. package/dist/talkpilot/calls/calls.statistics.types.d.ts +0 -39
  82. package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +0 -1
  83. package/dist/talkpilot/calls/calls.statistics.types.js +0 -3
  84. package/dist/talkpilot/calls/calls.statistics.types.js.map +0 -1
  85. package/dist/utils/date.utils.d.ts +0 -49
  86. package/dist/utils/date.utils.d.ts.map +0 -1
  87. package/dist/utils/date.utils.js +0 -103
  88. package/dist/utils/date.utils.js.map +0 -1
  89. package/dist/utils/statistics.aggregation.d.ts +0 -20
  90. package/dist/utils/statistics.aggregation.d.ts.map +0 -1
  91. package/dist/utils/statistics.aggregation.js +0 -43
  92. package/dist/utils/statistics.aggregation.js.map +0 -1
  93. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +0 -104
  94. package/src/municipal/tickets/tickets.constants.ts +0 -8
  95. package/src/municipal/tickets/tickets.statistics.aggregation.ts +0 -113
  96. package/src/municipal/tickets/tickets.statistics.getters.ts +0 -93
  97. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +0 -281
  98. package/src/talkpilot/calls/calls.constants.ts +0 -20
  99. package/src/talkpilot/calls/calls.statistics.getters.ts +0 -525
  100. package/src/talkpilot/calls/calls.statistics.types.ts +0 -44
  101. package/src/utils/date.utils.ts +0 -116
@@ -1,4 +1,5 @@
1
1
  import { CityName, getDb, ObjectId, Ticket } from "../index";
2
+ import type { SubjectStatsItem } from "./tickets.types";
2
3
  import { Collection, Filter, ObjectId as MongoObjectId } from "mongodb";
3
4
 
4
5
  /**
@@ -67,6 +68,145 @@ export const deleteTicket = async (ticketId: string): Promise<boolean> => {
67
68
  return result.deletedCount > 0;
68
69
  };
69
70
 
71
+ /**
72
+ * Count tickets by city and date range (createdAt converted to given timezone).
73
+ * Used as "open" tickets count when status is not available.
74
+ */
75
+ export async function getTicketsCountByCityAndDateRange(
76
+ cityName: string,
77
+ startStr: string,
78
+ endStr: string,
79
+ timezone: string,
80
+ ): Promise<number> {
81
+ const doc = await getTicketsCollection()
82
+ .aggregate<{ n: number }>([
83
+ { $match: { cityName, callSid: { $exists: true, $nin: [null, ""] } } },
84
+ {
85
+ $addFields: {
86
+ dateLocal: {
87
+ $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone },
88
+ },
89
+ },
90
+ },
91
+ { $match: { dateLocal: { $gte: startStr, $lte: endStr } } },
92
+ { $count: "n" },
93
+ ])
94
+ .next();
95
+ return doc?.n ?? 0;
96
+ }
97
+
98
+ /**
99
+ * Count of tickets by department (subject) from departmentsSubjects. Different departments (subject_id) are grouped, and sub-subjects are unified under their department.
100
+ * Date filter: createdAt in [startStr, endStr] (in the given timezone). Fallback to "Unclassified" when no match is found in the lookup.
101
+ */
102
+ export async function getTicketsSubjectStats(
103
+ cityName: string,
104
+ startStr: string,
105
+ endStr: string,
106
+ timezone: string,
107
+ ): Promise<SubjectStatsItem[]> {
108
+ const coll = getTicketsCollection();
109
+ const rows = await coll
110
+ .aggregate<{ _id: string; subject: string; count: number }>([
111
+ { $match: { cityName } },
112
+ {
113
+ $addFields: {
114
+ dateLocal: {
115
+ $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone },
116
+ },
117
+ },
118
+ },
119
+ { $match: { dateLocal: { $gte: startStr, $lte: endStr } } },
120
+ {
121
+ $addFields: {
122
+ effectiveSubjectId: {
123
+ $ifNull: [
124
+ "$externalCallFields.event_subject_id",
125
+ {
126
+ $ifNull: [
127
+ "$externalCallFields.event_sub_subject_id",
128
+ "$externalCallFields.event_sub_subject_id2",
129
+ ],
130
+ },
131
+ ],
132
+ },
133
+ },
134
+ },
135
+ {
136
+ $lookup: {
137
+ from: "departmentsSubjects",
138
+ let: { sid: "$effectiveSubjectId", c: cityName },
139
+ pipeline: [
140
+ {
141
+ $match: {
142
+ $expr: {
143
+ $and: [
144
+ { $eq: ["$cityName", "$$c"] },
145
+ {
146
+ $or: [
147
+ { $eq: ["$subject_id", { $ifNull: ["$$sid", ""] }] },
148
+ {
149
+ $eq: ["$sub_subject_id", { $ifNull: ["$$sid", ""] }],
150
+ },
151
+ {
152
+ $eq: ["$sub_subject_id2", { $ifNull: ["$$sid", ""] }],
153
+ },
154
+ ],
155
+ },
156
+ ],
157
+ },
158
+ },
159
+ },
160
+ { $limit: 1 },
161
+ ],
162
+ as: "subj",
163
+ },
164
+ },
165
+ {
166
+ $addFields: {
167
+ subject_id: {
168
+ $cond: {
169
+ if: { $gt: [{ $size: "$subj" }, 0] },
170
+ then: {
171
+ $ifNull: [{ $arrayElemAt: ["$subj.subject_id", 0] }, ""],
172
+ },
173
+ else: "",
174
+ },
175
+ },
176
+ subject: {
177
+ $cond: {
178
+ if: { $gt: [{ $size: "$subj" }, 0] },
179
+ then: {
180
+ $ifNull: [
181
+ { $arrayElemAt: ["$subj.subjectName", 0] },
182
+ "Unclassified",
183
+ ],
184
+ },
185
+ else: "Unclassified",
186
+ },
187
+ },
188
+ },
189
+ },
190
+ {
191
+ $group: {
192
+ _id: "$subject_id",
193
+ subject: { $first: "$subject" },
194
+ count: { $sum: 1 },
195
+ },
196
+ },
197
+ { $sort: { count: -1 } },
198
+ ])
199
+ .toArray();
200
+
201
+ const total = rows.reduce((s: number, r: any) => s + r.count, 0);
202
+ return rows.map((r: any) => ({
203
+ subject_name: r.subject,
204
+ subject_id: r._id,
205
+ count: r.count,
206
+ percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
207
+ }));
208
+ }
209
+
70
210
  export const findTicketByQuery = async (query: Partial<Ticket>) => {
71
211
  return await getTicketsCollection().findOne(query);
72
212
  };
@@ -9,6 +9,7 @@ export type Ticket = {
9
9
  callSid?: string;
10
10
  cityName: CityName;
11
11
  externalCallFields: {
12
+ // Request fields
12
13
  first_name?: string;
13
14
  last_name?: string;
14
15
  event_description?: string;
@@ -17,9 +18,10 @@ export type Ticket = {
17
18
  event_subject_id?: string;
18
19
  event_sub_subject_id?: string;
19
20
  event_sub_subject_id2?: string | null;
21
+ // Response fields (only if external call was successful)
20
22
  call_number?: string;
21
- status?: number;
22
- error?: string;
23
+ status?: number; // Response status from Bina API (1 = success)
24
+ error?: string; // Error message from Bina API if any
23
25
  image1?: string;
24
26
  image2?: string;
25
27
  image3?: string;
@@ -32,17 +34,10 @@ export type Ticket = {
32
34
 
33
35
  export type TicketDoc = WithId<Ticket>;
34
36
 
35
- export type SubjectItem = {
36
- subject: string;
37
+ /** Call count by department (subject). */
38
+ export type SubjectStatsItem = {
39
+ subject_name: string;
40
+ subject_id: string;
37
41
  count: number;
38
- };
39
-
40
- export type TicketStatsDateScope = {
41
- from?: Date;
42
- to?: Date;
43
- };
44
-
45
- export type TicketStatsDateRange = {
46
- from: Date;
47
- to: Date;
42
+ percentage: number;
48
43
  };
@@ -25,8 +25,8 @@ describe("getDashboardStats", () => {
25
25
 
26
26
  const params = {
27
27
  clientId,
28
- startDate: "2026-05-01",
29
- endDate: "2026-05-31",
28
+ startDate: "2026-05-01T00:00:00.000Z",
29
+ endDate: "2026-05-31T23:59:59.999Z",
30
30
  };
31
31
 
32
32
  const result = await getDashboardStats(params);
@@ -35,12 +35,115 @@ describe("getDashboardStats", () => {
35
35
  expect(result.kpis.totalCalls).toBe(0);
36
36
  expect(result.kpis.completedCount).toBe(0);
37
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,
38
+ expect(result.kpis.busyCount).toBe(0);
39
+ expect(result.kpis.timeSavedMinutes).toBe(0);
40
+ // ~30 day range -> daily buckets, gap-filled with zeros across the whole range
41
+ expect(result.charts.volumeGranularity).toBe("day");
42
+ expect(result.charts.volumeData.length).toBeGreaterThan(0);
43
+ expect(result.charts.volumeData.every((d) => d.completed === 0)).toBe(true);
44
+ expect(result.charts.volumeData[0].date).toBe("2026-05-01");
45
+ expect(
46
+ result.charts.volumeData[result.charts.volumeData.length - 1].date,
47
+ ).toBe("2026-05-31");
48
+ });
49
+
50
+ it("should pick a bucket size that keeps the volume chart readable across range lengths", async () => {
51
+ const clientId = "client-dash-456";
52
+
53
+ await getClientsConfigCollection().insertOne({
54
+ clientId,
55
+ timezone: "UTC",
56
+ products: {},
57
+ } as any);
58
+
59
+ const oneDay = await getDashboardStats({
60
+ clientId,
61
+ startDate: "2026-05-01T00:00:00.000Z",
62
+ endDate: "2026-05-01T23:59:59.999Z",
63
+ });
64
+ expect(oneDay.charts.volumeGranularity).toBe("hour");
65
+
66
+ const threeWeeks = await getDashboardStats({
67
+ clientId,
68
+ startDate: "2026-05-01T00:00:00.000Z",
69
+ endDate: "2026-05-21T23:59:59.999Z",
70
+ });
71
+ expect(threeWeeks.charts.volumeGranularity).toBe("day");
72
+
73
+ const fourMonths = await getDashboardStats({
74
+ clientId,
75
+ startDate: "2026-01-01T00:00:00.000Z",
76
+ endDate: "2026-04-30T23:59:59.999Z",
77
+ });
78
+ expect(fourMonths.charts.volumeGranularity).toBe("week");
79
+
80
+ const eighteenMonths = await getDashboardStats({
81
+ clientId,
82
+ startDate: "2025-01-01T00:00:00.000Z",
83
+ endDate: "2026-06-30T23:59:59.999Z",
84
+ });
85
+ expect(eighteenMonths.charts.volumeGranularity).toBe("month");
86
+
87
+ const threeYears = await getDashboardStats({
88
+ clientId,
89
+ startDate: "2023-01-01T00:00:00.000Z",
90
+ endDate: "2026-01-01T23:59:59.999Z",
91
+ });
92
+ expect(threeYears.charts.volumeGranularity).toBe("quarter");
93
+
94
+ const fiveYears = await getDashboardStats({
95
+ clientId,
96
+ startDate: "2021-01-01T00:00:00.000Z",
97
+ endDate: "2026-01-01T23:59:59.999Z",
98
+ });
99
+ expect(fiveYears.charts.volumeGranularity).toBe("year");
100
+ });
101
+
102
+ it("should fill gaps with zero-count buckets so the chart has no missing points", async () => {
103
+ const clientId = "client-dash-789";
104
+
105
+ await getClientsConfigCollection().insertOne({
106
+ clientId,
107
+ timezone: "UTC",
108
+ products: {},
109
+ } as any);
110
+
111
+ // Sparse data: only two of the day's hours have calls.
112
+ await getCallsCollection().insertMany([
113
+ {
114
+ clientId,
115
+ status: "completed",
116
+ callLength: 60,
117
+ createdAt: new Date("2026-05-01T09:15:00Z"),
118
+ },
119
+ {
120
+ clientId,
121
+ status: "completed",
122
+ callLength: 90,
123
+ createdAt: new Date("2026-05-01T18:45:00Z"),
124
+ },
125
+ ] as any);
126
+
127
+ const result = await getDashboardStats({
128
+ clientId,
129
+ startDate: "2026-05-01T00:00:00.000Z",
130
+ endDate: "2026-05-01T23:59:59.999Z",
44
131
  });
132
+
133
+ expect(result.charts.volumeGranularity).toBe("hour");
134
+ // A full day of hourly buckets, with no gaps, sorted chronologically.
135
+ expect(result.charts.volumeData).toHaveLength(24);
136
+ expect(result.charts.volumeData[0].date).toBe("2026-05-01T00:00");
137
+ expect(result.charts.volumeData[23].date).toBe("2026-05-01T23:00");
138
+
139
+ const populated = result.charts.volumeData.filter((d) => d.completed > 0);
140
+ expect(populated).toHaveLength(2);
141
+ expect(populated.map((d) => d.date)).toEqual([
142
+ "2026-05-01T09:00",
143
+ "2026-05-01T18:00",
144
+ ]);
145
+
146
+ const empty = result.charts.volumeData.filter((d) => d.completed === 0);
147
+ expect(empty).toHaveLength(22);
45
148
  });
46
149
  });
@@ -2,10 +2,8 @@ import { ObjectId, Sort, WithId } from "mongodb";
2
2
  import { TranscriptionSegment } from "../results";
3
3
  import { LeadProperty } from "../leads";
4
4
 
5
- import {
6
- CONFERENCE_ROLE_CUSTOMER,
7
- CONFERENCE_ROLE_SUPERVISOR,
8
- } from "./calls.constants";
5
+ export const CONFERENCE_ROLE_CUSTOMER = "customer" as const;
6
+ export const CONFERENCE_ROLE_SUPERVISOR = "supervisor" as const;
9
7
 
10
8
  export type ConferenceRole =
11
9
  | typeof CONFERENCE_ROLE_CUSTOMER