@talkpilot/core-db 1.2.1 → 1.3.0

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 (147) hide show
  1. package/README.md +117 -108
  2. package/README_OLD.md +160 -0
  3. package/dist/municipal/tickets/index.d.ts +2 -1
  4. package/dist/municipal/tickets/index.d.ts.map +1 -1
  5. package/dist/municipal/tickets/index.js +1 -0
  6. package/dist/municipal/tickets/index.js.map +1 -1
  7. package/dist/municipal/tickets/tickets.constants.d.ts +7 -0
  8. package/dist/municipal/tickets/tickets.constants.d.ts.map +1 -0
  9. package/dist/municipal/tickets/tickets.constants.js +10 -0
  10. package/dist/municipal/tickets/tickets.constants.js.map +1 -0
  11. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +12 -0
  12. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +1 -0
  13. package/dist/municipal/tickets/tickets.deprecated.getters.js +131 -0
  14. package/dist/municipal/tickets/tickets.deprecated.getters.js.map +1 -0
  15. package/dist/municipal/tickets/tickets.getters.d.ts +0 -11
  16. package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
  17. package/dist/municipal/tickets/tickets.getters.js +0 -128
  18. package/dist/municipal/tickets/tickets.getters.js.map +1 -1
  19. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +45 -0
  20. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +1 -0
  21. package/dist/municipal/tickets/tickets.statistics.aggregation.js +98 -0
  22. package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +1 -0
  23. package/dist/municipal/tickets/tickets.statistics.dates.d.ts +7 -0
  24. package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +1 -0
  25. package/dist/municipal/tickets/tickets.statistics.dates.js +40 -0
  26. package/dist/municipal/tickets/tickets.statistics.dates.js.map +1 -0
  27. package/dist/municipal/tickets/tickets.statistics.getters.d.ts +9 -0
  28. package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -0
  29. package/dist/municipal/tickets/tickets.statistics.getters.js +55 -0
  30. package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -0
  31. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +53 -0
  32. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +1 -0
  33. package/dist/municipal/tickets/tickets.statistics.pipeline.js +112 -0
  34. package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +1 -0
  35. package/dist/municipal/tickets/tickets.statistics.utils.d.ts +7 -0
  36. package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +1 -0
  37. package/dist/municipal/tickets/tickets.statistics.utils.js +40 -0
  38. package/dist/municipal/tickets/tickets.statistics.utils.js.map +1 -0
  39. package/dist/municipal/tickets/tickets.types.d.ts +10 -5
  40. package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
  41. package/dist/talkpilot/calls/calls.constants.d.ts +17 -0
  42. package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -0
  43. package/dist/talkpilot/calls/calls.constants.js +20 -0
  44. package/dist/talkpilot/calls/calls.constants.js.map +1 -0
  45. package/dist/talkpilot/calls/calls.getters.d.ts +3 -2
  46. package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
  47. package/dist/talkpilot/calls/calls.getters.js +1 -2
  48. package/dist/talkpilot/calls/calls.getters.js.map +1 -1
  49. package/dist/talkpilot/calls/calls.statistics.getters.d.ts +19 -0
  50. package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -0
  51. package/dist/talkpilot/calls/calls.statistics.getters.js +375 -0
  52. package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -0
  53. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +12 -0
  54. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +1 -0
  55. package/dist/talkpilot/calls/calls.statistics.ticketScope.js +37 -0
  56. package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +1 -0
  57. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +17 -0
  58. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +1 -0
  59. package/dist/talkpilot/calls/calls.statistics.tickets.js +33 -0
  60. package/dist/talkpilot/calls/calls.statistics.tickets.js.map +1 -0
  61. package/dist/talkpilot/calls/calls.statistics.types.d.ts +39 -0
  62. package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -0
  63. package/dist/talkpilot/calls/calls.statistics.types.js +3 -0
  64. package/dist/talkpilot/calls/calls.statistics.types.js.map +1 -0
  65. package/dist/talkpilot/calls/calls.types.d.ts +6 -10
  66. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  67. package/dist/talkpilot/calls/calls.types.js +0 -3
  68. package/dist/talkpilot/calls/calls.types.js.map +1 -1
  69. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +36 -0
  70. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -0
  71. package/dist/talkpilot/calls/dashboard/calls.dashboard.js +208 -0
  72. package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -0
  73. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +66 -0
  74. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -0
  75. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js +3 -0
  76. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js.map +1 -0
  77. package/dist/talkpilot/calls/index.d.ts +4 -0
  78. package/dist/talkpilot/calls/index.d.ts.map +1 -1
  79. package/dist/talkpilot/calls/index.js +4 -0
  80. package/dist/talkpilot/calls/index.js.map +1 -1
  81. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts +2 -1
  82. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts.map +1 -1
  83. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js +14 -0
  84. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js.map +1 -1
  85. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts +4 -1
  86. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts.map +1 -1
  87. package/dist/talkpilot/clientsConfig/clientsConfig.types.js +6 -0
  88. package/dist/talkpilot/clientsConfig/clientsConfig.types.js.map +1 -1
  89. package/dist/talkpilot/flows/flows.schema.js +1 -1
  90. package/dist/talkpilot/phone_numbers/index.d.ts +2 -2
  91. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts +1 -1
  92. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts.map +1 -1
  93. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js +5 -3
  94. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js.map +1 -1
  95. package/dist/talkpilot/phone_numbers/phone_numbers.schema.js +12 -12
  96. package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts +4 -4
  97. package/dist/talkpilot/results/results.getter.d.ts.map +1 -1
  98. package/dist/talkpilot/results/results.getter.js.map +1 -1
  99. package/dist/talkpilot/retry_analyze/retryAnalyze.getters.d.ts.map +1 -1
  100. package/dist/talkpilot/retry_analyze/retryAnalyze.getters.js.map +1 -1
  101. package/dist/utils/date.utils.d.ts +49 -0
  102. package/dist/utils/date.utils.d.ts.map +1 -0
  103. package/dist/utils/date.utils.js +103 -0
  104. package/dist/utils/date.utils.js.map +1 -0
  105. package/dist/utils/shared.types.d.ts +5 -0
  106. package/dist/utils/shared.types.d.ts.map +1 -0
  107. package/dist/utils/shared.types.js +3 -0
  108. package/dist/utils/shared.types.js.map +1 -0
  109. package/dist/utils/statistics.aggregation.d.ts +20 -0
  110. package/dist/utils/statistics.aggregation.d.ts.map +1 -0
  111. package/dist/utils/statistics.aggregation.js +43 -0
  112. package/dist/utils/statistics.aggregation.js.map +1 -0
  113. package/package.json +2 -1
  114. package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +1 -37
  115. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +104 -0
  116. package/src/municipal/tickets/index.ts +2 -1
  117. package/src/municipal/tickets/tickets.constants.ts +8 -0
  118. package/src/municipal/tickets/tickets.getters.ts +0 -140
  119. package/src/municipal/tickets/tickets.statistics.aggregation.ts +113 -0
  120. package/src/municipal/tickets/tickets.statistics.getters.ts +93 -0
  121. package/src/municipal/tickets/tickets.types.ts +14 -9
  122. package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +46 -0
  123. package/src/talkpilot/calls/__tests__/calls.spec.ts +48 -30
  124. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +281 -0
  125. package/src/talkpilot/calls/calls.constants.ts +20 -0
  126. package/src/talkpilot/calls/calls.getters.ts +6 -6
  127. package/src/talkpilot/calls/calls.statistics.getters.ts +525 -0
  128. package/src/talkpilot/calls/calls.statistics.types.ts +44 -0
  129. package/src/talkpilot/calls/calls.types.ts +16 -15
  130. package/src/talkpilot/calls/dashboard/calls.dashboard.ts +243 -0
  131. package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +70 -0
  132. package/src/talkpilot/calls/index.ts +4 -0
  133. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.getters.spec.ts +53 -0
  134. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +19 -9
  135. package/src/talkpilot/clientsConfig/clientsConfig.getters.ts +34 -1
  136. package/src/talkpilot/clientsConfig/clientsConfig.types.ts +9 -1
  137. package/src/talkpilot/flows/__tests__/flows.schema.spec.ts +6 -2
  138. package/src/talkpilot/flows/flows.schema.ts +1 -1
  139. package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +40 -35
  140. package/src/talkpilot/phone_numbers/index.ts +2 -2
  141. package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +10 -6
  142. package/src/talkpilot/phone_numbers/phone_numbers.schema.ts +12 -12
  143. package/src/talkpilot/phone_numbers/phone_numbers.types.ts +4 -4
  144. package/src/talkpilot/results/results.getter.ts +6 -2
  145. package/src/talkpilot/retry_analyze/retryAnalyze.getters.ts +13 -4
  146. package/src/utils/date.utils.ts +116 -0
  147. package/src/utils/shared.types.ts +4 -0
@@ -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
+ }
@@ -0,0 +1,70 @@
1
+ export type DashboardHeatmapMetric = {
2
+ hour: number;
3
+ calls: number;
4
+ };
5
+
6
+ export type DashboardDailyTrendMetric = {
7
+ date: string;
8
+ count: number;
9
+ completed: number;
10
+ };
11
+
12
+ export type DashboardDurationSegments = {
13
+ short: number;
14
+ medium: number;
15
+ long: number;
16
+ };
17
+
18
+ export type DashboardSummaryMetrics = {
19
+ totalCalls: number;
20
+ avgDurationSeconds: number;
21
+ timeSavedMinutes: number;
22
+ successRate: number;
23
+ completedCount: number;
24
+ failedCount: number;
25
+ noAnswerCount: number;
26
+ busyCount: number;
27
+ };
28
+
29
+ export type DashboardVisualData = {
30
+ volumeData: DashboardDailyTrendMetric[];
31
+ heatmap: Record<string, DashboardHeatmapMetric[]>;
32
+ callLengthBuckets: DashboardDurationSegments;
33
+ };
34
+
35
+ export type DashboardReportQuery = {
36
+ clientId: string;
37
+ startDate: string;
38
+ endDate: string;
39
+ };
40
+
41
+ export type DashboardReportResponse = {
42
+ kpis: DashboardSummaryMetrics;
43
+ charts: DashboardVisualData;
44
+ };
45
+ type RawKpiResult = {
46
+ totalCalls: number;
47
+ totalDuration: number | null;
48
+ completedCount: number;
49
+ failedCount: number;
50
+ noAnswerCount: number;
51
+ busyCount: number;
52
+ };
53
+
54
+ export type RawDailyAggregationResult = {
55
+ _id: string;
56
+ count: number;
57
+ completed: number;
58
+ };
59
+
60
+ export type RawHourlyAggregationResult = {
61
+ _id: { day: string; hour: number };
62
+ count: number;
63
+ };
64
+
65
+ export interface DashboardAggregationResult {
66
+ kpis: RawKpiResult[];
67
+ dailyData: RawDailyAggregationResult[];
68
+ hourlyData: RawHourlyAggregationResult[];
69
+ callLengthBuckets: DashboardDurationSegments[];
70
+ }
@@ -1,2 +1,6 @@
1
+ export * from "./calls.constants";
1
2
  export * from "./calls.types";
2
3
  export * from "./calls.getters";
4
+ export * from "./calls.statistics.types";
5
+ export * from "./calls.statistics.getters";
6
+ export * from "./dashboard/calls.dashboard";
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2
+ import {
3
+ getClientsConfigCollection,
4
+ updateProductConfig,
5
+ } from "../clientsConfig.getters";
6
+
7
+ describe("updateProductConfig", () => {
8
+ beforeEach(async () => {
9
+ await getClientsConfigCollection().deleteMany({});
10
+ });
11
+
12
+ afterEach(async () => {
13
+ await getClientsConfigCollection().deleteMany({});
14
+ });
15
+
16
+ it("should update partial fields using correct dot notation", async () => {
17
+ const clientId = "client-123";
18
+ const collection = getClientsConfigCollection();
19
+
20
+ await collection.insertOne({
21
+ clientId,
22
+ products: {
23
+ websiteTalk: {
24
+ language: "English",
25
+ companyName: "OldCorp",
26
+ defaultBaseUrl: "https://old-url.com",
27
+ logoUrl: "",
28
+ timezone: "UTC",
29
+ },
30
+ },
31
+ } as any);
32
+
33
+ const updates = { language: "Hebrew", companyName: "TestCorp" };
34
+ await updateProductConfig(clientId, "websiteTalk", updates);
35
+
36
+ const updatedDoc = await collection.findOne({ clientId });
37
+
38
+ expect(updatedDoc).toBeDefined();
39
+ expect(updatedDoc?.products.websiteTalk?.language).toBe("Hebrew");
40
+ expect(updatedDoc?.products.websiteTalk?.companyName).toBe("TestCorp");
41
+ expect(updatedDoc?.products.websiteTalk?.defaultBaseUrl).toBe(
42
+ "https://old-url.com",
43
+ );
44
+ });
45
+
46
+ it("should throw an error if no client config is found", async () => {
47
+ await expect(
48
+ updateProductConfig("invalid-client", "websiteTalk", {
49
+ language: "English",
50
+ }),
51
+ ).rejects.toThrow("No client config found for clientId: invalid-client");
52
+ });
53
+ });
@@ -38,8 +38,8 @@ describe("db.clientsConfig", () => {
38
38
  defaultBaseUrl: "https://acme.example",
39
39
  scheduler: {
40
40
  type: "cron",
41
- schedule: "0 9 * * 1-5"
42
- },
41
+ schedule: "0 9 * * 1-5",
42
+ },
43
43
  },
44
44
  },
45
45
  });
@@ -94,10 +94,12 @@ describe("db.clientsConfig", () => {
94
94
  const result = await getClientConfig(clientId);
95
95
 
96
96
  // Then
97
- expect(result?.products.municipal?.moked_106?.toolsPrompts).toMatchObject({
98
- findSubjectsSystemPrompt: "Custom subjects prompt",
99
- findStreetsSystemPrompt: "Custom streets prompt",
100
- });
97
+ expect(result?.products.municipal?.moked_106?.toolsPrompts).toMatchObject(
98
+ {
99
+ findSubjectsSystemPrompt: "Custom subjects prompt",
100
+ findStreetsSystemPrompt: "Custom streets prompt",
101
+ },
102
+ );
101
103
  });
102
104
 
103
105
  it("given moked_106 with findSubjectsSystemPrompt only when saved and retrieved then findStreetsSystemPrompt is undefined", async () => {
@@ -123,8 +125,14 @@ describe("db.clientsConfig", () => {
123
125
  const result = await getClientConfig(clientId);
124
126
 
125
127
  // Then
126
- expect(result?.products.municipal?.moked_106?.toolsPrompts?.findSubjectsSystemPrompt).toBe("Subjects only prompt");
127
- expect(result?.products.municipal?.moked_106?.toolsPrompts?.findStreetsSystemPrompt).toBeUndefined();
128
+ expect(
129
+ result?.products.municipal?.moked_106?.toolsPrompts
130
+ ?.findSubjectsSystemPrompt,
131
+ ).toBe("Subjects only prompt");
132
+ expect(
133
+ result?.products.municipal?.moked_106?.toolsPrompts
134
+ ?.findStreetsSystemPrompt,
135
+ ).toBeUndefined();
128
136
  });
129
137
 
130
138
  it("given moked_106 without toolsPrompts when saved and retrieved then toolsPrompts is undefined", async () => {
@@ -148,7 +156,9 @@ describe("db.clientsConfig", () => {
148
156
  const result = await getClientConfig(clientId);
149
157
 
150
158
  // Then
151
- expect(result?.products.municipal?.moked_106?.toolsPrompts).toBeUndefined();
159
+ expect(
160
+ result?.products.municipal?.moked_106?.toolsPrompts,
161
+ ).toBeUndefined();
152
162
  });
153
163
 
154
164
  it("should support communications config", async () => {
@@ -1,4 +1,11 @@
1
- import { ClientConfigDoc, getDb, findClientByPhoneNumber } from "../index";
1
+ import {
2
+ ClientConfigDoc,
3
+ getDb,
4
+ findClientByPhoneNumber,
5
+ Products,
6
+ KNOWN_PRODUCT_KEYS,
7
+ KnownProductKey,
8
+ } from "../index";
2
9
  import { Collection } from "mongodb";
3
10
 
4
11
  export const getClientsConfigCollection = (): Collection<ClientConfigDoc> => {
@@ -20,3 +27,29 @@ export const createClientConfigDoc = async (
20
27
  ): Promise<void> => {
21
28
  await getClientsConfigCollection().insertOne(clientConfig);
22
29
  };
30
+
31
+ export async function updateProductConfig<K extends KnownProductKey>(
32
+ clientId: string,
33
+ productKey: K,
34
+ updates: Partial<Products[K]>,
35
+ ): Promise<void> {
36
+ if (!KNOWN_PRODUCT_KEYS.includes(productKey as KnownProductKey)) {
37
+ throw new Error(
38
+ `Invalid product key: "${productKey}". Must be one of: ${KNOWN_PRODUCT_KEYS.join(", ")}`,
39
+ );
40
+ }
41
+
42
+ const setOperations: Record<string, unknown> = {};
43
+ for (const [key, value] of Object.entries(updates)) {
44
+ setOperations[`products.${productKey}.${key}`] = value;
45
+ }
46
+
47
+ const result = await getClientsConfigCollection().updateOne(
48
+ { clientId },
49
+ { $set: setOperations },
50
+ );
51
+
52
+ if (result.matchedCount === 0) {
53
+ throw new Error(`No client config found for clientId: ${clientId}`);
54
+ }
55
+ }
@@ -1,4 +1,5 @@
1
1
  import { WithId } from "mongodb";
2
+ import { CallLengthThresholds } from "src/utils/shared.types";
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Moked106 (MIS)
@@ -26,6 +27,13 @@ export type Moked106Config = {
26
27
  toolsPrompts?: Moked106ToolPrompts;
27
28
  };
28
29
 
30
+ export const KNOWN_PRODUCT_KEYS = [
31
+ "municipal",
32
+ "clinics",
33
+ "websiteTalk",
34
+ ] as const;
35
+ export type KnownProductKey = (typeof KNOWN_PRODUCT_KEYS)[number];
36
+
29
37
  /** Product config for the MIS */
30
38
  export type MunicipalProduct = {
31
39
  moked_106?: Moked106Config;
@@ -55,7 +63,6 @@ export type WebsiteTalkProduct = {
55
63
  timezone: string;
56
64
  language: string;
57
65
  defaultBaseUrl: string;
58
- scheduler?: WebsiteTalkScheduler;
59
66
  };
60
67
 
61
68
  // ---------------------------------------------------------------------------
@@ -112,6 +119,7 @@ export type ClientConfig<P extends Products = Products> = {
112
119
  products: P;
113
120
  timezone?: string;
114
121
  communications?: CommunicationsConfig;
122
+ callLengthThresholds?: CallLengthThresholds;
115
123
  };
116
124
 
117
125
  export type ClientConfigDoc<P extends Products = Products> = WithId<
@@ -61,7 +61,11 @@ describe("flowMongoSchema", () => {
61
61
  "backgroundToolOnce",
62
62
  ]);
63
63
 
64
- expect(toolItem.properties).toHaveProperty('backgroundContinuationInstructions');
65
- expect(toolItem.properties.backgroundContinuationInstructions.bsonType).toBe('string');
64
+ expect(toolItem.properties).toHaveProperty(
65
+ "backgroundContinuationInstructions",
66
+ );
67
+ expect(
68
+ toolItem.properties.backgroundContinuationInstructions.bsonType,
69
+ ).toBe("string");
66
70
  });
67
71
  });
@@ -143,7 +143,7 @@ export const flowMongoSchema = {
143
143
  bsonType: "string",
144
144
  enum: ["backgroundToolAlways", "backgroundToolOnce"],
145
145
  },
146
- backgroundContinuationInstructions: { bsonType: 'string' },
146
+ backgroundContinuationInstructions: { bsonType: "string" },
147
147
  },
148
148
  additionalProperties: false,
149
149
  },
@@ -6,14 +6,14 @@ import {
6
6
  getClientPhoneData,
7
7
  createPhoneNumberEntity,
8
8
  createPurchasedPhoneNumber,
9
- } from '../phone_numbers.getter';
10
- import { getFlowsCollection } from '../../flows/flows.getter';
11
- import { createFlow, createPhoneNumber } from '../../../test-utils/factories';
12
- import { ObjectId } from 'mongodb';
13
-
14
- describe('db.phoneNumbers', () => {
15
- describe('getPhoneDataByPhoneNumber', () => {
16
- it('return phone number data with flow', async () => {
9
+ } from "../phone_numbers.getter";
10
+ import { getFlowsCollection } from "../../flows/flows.getter";
11
+ import { createFlow, createPhoneNumber } from "../../../test-utils/factories";
12
+ import { ObjectId } from "mongodb";
13
+
14
+ describe("db.phoneNumbers", () => {
15
+ describe("getPhoneDataByPhoneNumber", () => {
16
+ it("return phone number data with flow", async () => {
17
17
  const flow = createFlow({
18
18
  clientId: "test-client-id",
19
19
  conversationSettings: {
@@ -129,9 +129,9 @@ describe('db.phoneNumbers', () => {
129
129
  });
130
130
  });
131
131
 
132
- describe('getPhoneNumbersForFlows', () => {
133
- it('returns all phone numbers for the client with flow ObjectIds, newest first', async () => {
134
- const clientId = 'flowsClient';
132
+ describe("getPhoneNumbersForFlows", () => {
133
+ it("returns all phone numbers for the client with flow ObjectIds, newest first", async () => {
134
+ const clientId = "flowsClient";
135
135
  const flow1 = createFlow({ clientId });
136
136
  const flow2 = createFlow({ clientId });
137
137
  await getFlowsCollection().insertMany([flow1, flow2]);
@@ -139,16 +139,16 @@ describe('db.phoneNumbers', () => {
139
139
  const older = createPhoneNumber({
140
140
  client_id: clientId,
141
141
  flow_id: flow1._id,
142
- phone_number: '+100',
142
+ phone_number: "+100",
143
143
  is_primary: true,
144
- createdAt: new Date('2023-01-01'),
144
+ createdAt: new Date("2023-01-01"),
145
145
  });
146
146
  const newer = createPhoneNumber({
147
147
  client_id: clientId,
148
148
  flow_id: flow2._id,
149
- phone_number: '+200',
149
+ phone_number: "+200",
150
150
  is_primary: false,
151
- createdAt: new Date('2023-06-01'),
151
+ createdAt: new Date("2023-06-01"),
152
152
  });
153
153
 
154
154
  await getPhoneNumbersCollection().insertMany([older, newer]);
@@ -158,27 +158,30 @@ describe('db.phoneNumbers', () => {
158
158
  expect(result).toHaveLength(2);
159
159
  expect(result[0]).toEqual({
160
160
  flowId: String(flow2._id),
161
- phoneNumber: '+200',
161
+ phoneNumber: "+200",
162
162
  isPrimary: false,
163
163
  });
164
164
  expect(result[1]).toEqual({
165
165
  flowId: String(flow1._id),
166
- phoneNumber: '+100',
166
+ phoneNumber: "+100",
167
167
  isPrimary: true,
168
168
  });
169
169
  });
170
170
 
171
- it('returns an empty array when the client has no phone numbers', async () => {
172
- const result = await getPhoneNumbersForFlows('noSuchClient');
171
+ it("returns an empty array when the client has no phone numbers", async () => {
172
+ const result = await getPhoneNumbersForFlows("noSuchClient");
173
173
  expect(result).toEqual([]);
174
174
  });
175
175
 
176
- it('does not include phone numbers for other clients', async () => {
177
- const clientA = 'clientA';
178
- const clientB = 'clientB';
176
+ it("does not include phone numbers for other clients", async () => {
177
+ const clientA = "clientA";
178
+ const clientB = "clientB";
179
179
  const flow = createFlow();
180
180
  await getFlowsCollection().insertOne(flow);
181
- const phone = createPhoneNumber({ client_id: clientA, flow_id: flow._id });
181
+ const phone = createPhoneNumber({
182
+ client_id: clientA,
183
+ flow_id: flow._id,
184
+ });
182
185
  await getPhoneNumbersCollection().insertOne(phone);
183
186
 
184
187
  const result = await getPhoneNumbersForFlows(clientB);
@@ -186,9 +189,9 @@ describe('db.phoneNumbers', () => {
186
189
  });
187
190
  });
188
191
 
189
- describe('createPhoneNumberEntity', () => {
190
- it('creates first phone number as primary', async () => {
191
- const clientId = 'newClient';
192
+ describe("createPhoneNumberEntity", () => {
193
+ it("creates first phone number as primary", async () => {
194
+ const clientId = "newClient";
192
195
  const flowId = new ObjectId().toHexString();
193
196
  const phoneNumber = "+123456789";
194
197
 
@@ -215,14 +218,14 @@ describe('db.phoneNumbers', () => {
215
218
  });
216
219
  });
217
220
 
218
- describe('createPurchasedPhoneNumber', () => {
219
- it('persists a phone_numbers document with provider payload and flow', async () => {
220
- const clientId = 'purchasedClientDb';
221
+ describe("createPurchasedPhoneNumber", () => {
222
+ it("persists a phone_numbers document with provider payload and flow", async () => {
223
+ const clientId = "purchasedClientDb";
221
224
  const flowId = new ObjectId().toHexString();
222
- const phoneNumber = '+15551234567';
225
+ const phoneNumber = "+15551234567";
223
226
  const providerPayload = {
224
- provider: 'twilio' as const,
225
- provider_sid: 'PNxxxxxxxx',
227
+ provider: "twilio" as const,
228
+ provider_sid: "PNxxxxxxxx",
226
229
  };
227
230
 
228
231
  await createPurchasedPhoneNumber(
@@ -233,13 +236,15 @@ describe('db.phoneNumbers', () => {
233
236
  );
234
237
 
235
238
  // Re-read from Mongo to confirm insert succeeded (not only the function return value).
236
- const stored = await getPhoneNumbersCollection().findOne({ phone_number: phoneNumber });
239
+ const stored = await getPhoneNumbersCollection().findOne({
240
+ phone_number: phoneNumber,
241
+ });
237
242
  expect(stored).not.toBeNull();
238
243
  expect(stored).toMatchObject({
239
244
  phone_number: phoneNumber,
240
245
  client_id: clientId,
241
- provider: 'twilio',
242
- provider_sid: 'PNxxxxxxxx',
246
+ provider: "twilio",
247
+ provider_sid: "PNxxxxxxxx",
243
248
  flow_id: new ObjectId(flowId),
244
249
  });
245
250
  });
@@ -1,2 +1,2 @@
1
- export * from './phone_numbers.getter';
2
- export type * from './phone_numbers.types';
1
+ export * from "./phone_numbers.getter";
2
+ export type * from "./phone_numbers.types";