@talkpilot/core-db 1.2.2 → 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 (100) hide show
  1. package/.cursor/rules/development.mdc +65 -65
  2. package/DEVELOPMENT.md +98 -98
  3. package/README.md +169 -139
  4. package/README_OLD.md +160 -160
  5. package/dist/municipal/tickets/index.d.ts +2 -1
  6. package/dist/municipal/tickets/index.d.ts.map +1 -1
  7. package/dist/municipal/tickets/index.js +1 -0
  8. package/dist/municipal/tickets/index.js.map +1 -1
  9. package/dist/municipal/tickets/tickets.constants.d.ts +7 -0
  10. package/dist/municipal/tickets/tickets.constants.d.ts.map +1 -0
  11. package/dist/municipal/tickets/tickets.constants.js +10 -0
  12. package/dist/municipal/tickets/tickets.constants.js.map +1 -0
  13. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +12 -0
  14. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +1 -0
  15. package/dist/municipal/tickets/tickets.deprecated.getters.js +131 -0
  16. package/dist/municipal/tickets/tickets.deprecated.getters.js.map +1 -0
  17. package/dist/municipal/tickets/tickets.getters.d.ts +0 -11
  18. package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
  19. package/dist/municipal/tickets/tickets.getters.js +0 -128
  20. package/dist/municipal/tickets/tickets.getters.js.map +1 -1
  21. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +45 -0
  22. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +1 -0
  23. package/dist/municipal/tickets/tickets.statistics.aggregation.js +98 -0
  24. package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +1 -0
  25. package/dist/municipal/tickets/tickets.statistics.dates.d.ts +7 -0
  26. package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +1 -0
  27. package/dist/municipal/tickets/tickets.statistics.dates.js +40 -0
  28. package/dist/municipal/tickets/tickets.statistics.dates.js.map +1 -0
  29. package/dist/municipal/tickets/tickets.statistics.getters.d.ts +9 -0
  30. package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -0
  31. package/dist/municipal/tickets/tickets.statistics.getters.js +55 -0
  32. package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -0
  33. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +53 -0
  34. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +1 -0
  35. package/dist/municipal/tickets/tickets.statistics.pipeline.js +112 -0
  36. package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +1 -0
  37. package/dist/municipal/tickets/tickets.statistics.utils.d.ts +7 -0
  38. package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +1 -0
  39. package/dist/municipal/tickets/tickets.statistics.utils.js +40 -0
  40. package/dist/municipal/tickets/tickets.statistics.utils.js.map +1 -0
  41. package/dist/municipal/tickets/tickets.types.d.ts +10 -5
  42. package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
  43. package/dist/talkpilot/calls/calls.constants.d.ts +17 -0
  44. package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -0
  45. package/dist/talkpilot/calls/calls.constants.js +20 -0
  46. package/dist/talkpilot/calls/calls.constants.js.map +1 -0
  47. package/dist/talkpilot/calls/calls.statistics.getters.d.ts +19 -0
  48. package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -0
  49. package/dist/talkpilot/calls/calls.statistics.getters.js +375 -0
  50. package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -0
  51. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +12 -0
  52. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +1 -0
  53. package/dist/talkpilot/calls/calls.statistics.ticketScope.js +37 -0
  54. package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +1 -0
  55. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +17 -0
  56. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +1 -0
  57. package/dist/talkpilot/calls/calls.statistics.tickets.js +33 -0
  58. package/dist/talkpilot/calls/calls.statistics.tickets.js.map +1 -0
  59. package/dist/talkpilot/calls/calls.statistics.types.d.ts +39 -0
  60. package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -0
  61. package/dist/talkpilot/calls/calls.statistics.types.js +3 -0
  62. package/dist/talkpilot/calls/calls.statistics.types.js.map +1 -0
  63. package/dist/talkpilot/calls/calls.types.d.ts +3 -3
  64. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  65. package/dist/talkpilot/calls/calls.types.js +0 -3
  66. package/dist/talkpilot/calls/calls.types.js.map +1 -1
  67. package/dist/talkpilot/calls/index.d.ts +3 -0
  68. package/dist/talkpilot/calls/index.d.ts.map +1 -1
  69. package/dist/talkpilot/calls/index.js +3 -0
  70. package/dist/talkpilot/calls/index.js.map +1 -1
  71. package/dist/utils/date.utils.d.ts +49 -0
  72. package/dist/utils/date.utils.d.ts.map +1 -0
  73. package/dist/utils/date.utils.js +103 -0
  74. package/dist/utils/date.utils.js.map +1 -0
  75. package/dist/utils/statistics.aggregation.d.ts +20 -0
  76. package/dist/utils/statistics.aggregation.d.ts.map +1 -0
  77. package/dist/utils/statistics.aggregation.js +43 -0
  78. package/dist/utils/statistics.aggregation.js.map +1 -0
  79. package/jest.config.js +19 -19
  80. package/package.json +46 -46
  81. package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +1 -37
  82. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +104 -0
  83. package/src/municipal/tickets/index.ts +2 -1
  84. package/src/municipal/tickets/tickets.constants.ts +8 -0
  85. package/src/municipal/tickets/tickets.getters.ts +0 -140
  86. package/src/municipal/tickets/tickets.statistics.aggregation.ts +113 -0
  87. package/src/municipal/tickets/tickets.statistics.getters.ts +93 -0
  88. package/src/municipal/tickets/tickets.types.ts +14 -9
  89. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +281 -0
  90. package/src/talkpilot/calls/calls.constants.ts +20 -0
  91. package/src/talkpilot/calls/calls.statistics.getters.ts +525 -0
  92. package/src/talkpilot/calls/calls.statistics.types.ts +44 -0
  93. package/src/talkpilot/calls/calls.types.ts +6 -3
  94. package/src/talkpilot/calls/index.ts +3 -0
  95. package/src/utils/date.utils.ts +116 -0
  96. package/tsconfig.json +23 -23
  97. package/dist/talkpilot/calls/calls.dashboard.d.ts +0 -3
  98. package/dist/talkpilot/calls/calls.dashboard.d.ts.map +0 -1
  99. package/dist/talkpilot/calls/calls.dashboard.js +0 -191
  100. package/dist/talkpilot/calls/calls.dashboard.js.map +0 -1
@@ -0,0 +1,104 @@
1
+ import {
2
+ findCallSidsWithDraftTicketsByCity,
3
+ findCallSidsWithTicketsByCity,
4
+ findSubjectsByCityAndDateRange,
5
+ findSubjectsByCallSids,
6
+ getDepartmentsSubjectsCollection,
7
+ getTicketsCollection,
8
+ ticketStatsDateScopeFromYmd,
9
+ } from "../../../index";
10
+ import { createDepartmentSubject, createTicket } from "../../../test-utils/factories";
11
+
12
+ const CITY = "tests" as const;
13
+ const TZ = "Asia/Jerusalem";
14
+ const mayScope = () => ticketStatsDateScopeFromYmd("2026-05-01", "2026-05-31", TZ);
15
+
16
+ describe("tickets statistics getters", () => {
17
+ it("should scope callSid lookups to the provided createdAt range", async () => {
18
+ await getTicketsCollection().insertMany([
19
+ createTicket({
20
+ cityName: CITY,
21
+ callSid: "CA-in-range",
22
+ createdAt: new Date("2026-05-15T10:00:00.000Z"),
23
+ updatedAt: new Date("2026-05-15T10:00:00.000Z"),
24
+ }),
25
+ createTicket({
26
+ cityName: CITY,
27
+ callSid: "CA-out-of-range",
28
+ createdAt: new Date("2024-01-01T10:00:00.000Z"),
29
+ updatedAt: new Date("2024-01-01T10:00:00.000Z"),
30
+ }),
31
+ ]);
32
+ expect(await findCallSidsWithTicketsByCity(CITY, mayScope())).toEqual(["CA-in-range"]);
33
+ });
34
+
35
+ it("should treat whitespace-only event_subject_id as draft", async () => {
36
+ await getTicketsCollection().insertMany([
37
+ createTicket({
38
+ cityName: CITY,
39
+ callSid: "CA-draft-whitespace",
40
+ createdAt: new Date("2026-05-15T10:00:00.000Z"),
41
+ updatedAt: new Date("2026-05-15T10:00:00.000Z"),
42
+ externalCallFields: { event_subject_id: " \t " },
43
+ }),
44
+ createTicket({
45
+ cityName: CITY,
46
+ callSid: "CA-not-draft",
47
+ createdAt: new Date("2026-05-16T10:00:00.000Z"),
48
+ updatedAt: new Date("2026-05-16T10:00:00.000Z"),
49
+ externalCallFields: { event_subject_id: "100" },
50
+ }),
51
+ ]);
52
+ expect(await findCallSidsWithDraftTicketsByCity(CITY, mayScope())).toEqual(["CA-draft-whitespace"]);
53
+ });
54
+
55
+ it("should aggregate subjects by city and createdAt date scope", async () => {
56
+ await getDepartmentsSubjectsCollection().insertOne(
57
+ createDepartmentSubject({ cityName: CITY, subject_id: "200", subjectName: "Roads" }),
58
+ );
59
+ await getTicketsCollection().insertMany([
60
+ createTicket({
61
+ cityName: CITY,
62
+ callSid: "CA-subject-in-range",
63
+ createdAt: new Date("2026-05-15T10:00:00.000Z"),
64
+ updatedAt: new Date("2026-05-15T10:00:00.000Z"),
65
+ externalCallFields: { event_subject_id: "200" },
66
+ }),
67
+ createTicket({
68
+ cityName: CITY,
69
+ callSid: "CA-subject-out-of-range",
70
+ createdAt: new Date("2024-01-01T10:00:00.000Z"),
71
+ updatedAt: new Date("2024-01-01T10:00:00.000Z"),
72
+ externalCallFields: { event_subject_id: "200" },
73
+ }),
74
+ ]);
75
+ expect(await findSubjectsByCityAndDateRange(CITY, mayScope())).toEqual([
76
+ { subject: "Roads", count: 1 },
77
+ ]);
78
+ });
79
+
80
+ it("should exclude tickets outside dateScope when aggregating subjects by callSid", async () => {
81
+ await getDepartmentsSubjectsCollection().insertOne(
82
+ createDepartmentSubject({ cityName: CITY, subject_id: "300", subjectName: "Parks" }),
83
+ );
84
+ await getTicketsCollection().insertMany([
85
+ createTicket({
86
+ cityName: CITY,
87
+ callSid: "CA-shared-sid",
88
+ createdAt: new Date("2026-05-15T10:00:00.000Z"),
89
+ updatedAt: new Date("2026-05-15T10:00:00.000Z"),
90
+ externalCallFields: { event_subject_id: "300" },
91
+ }),
92
+ createTicket({
93
+ cityName: CITY,
94
+ callSid: "CA-shared-sid",
95
+ createdAt: new Date("2024-01-01T10:00:00.000Z"),
96
+ updatedAt: new Date("2024-01-01T10:00:00.000Z"),
97
+ externalCallFields: { event_subject_id: "300" },
98
+ }),
99
+ ]);
100
+ expect(await findSubjectsByCallSids(CITY, ["CA-shared-sid"], undefined, mayScope())).toEqual([
101
+ { subject: "Parks", count: 1 },
102
+ ]);
103
+ });
104
+ });
@@ -1,2 +1,3 @@
1
1
  export * from "./tickets.getters";
2
- export type { Ticket, TicketDoc, SubjectStatsItem } from "./tickets.types";
2
+ export * from "./tickets.statistics.getters";
3
+ export type { Ticket, TicketDoc, SubjectItem, TicketStatsDateScope } from "./tickets.types";
@@ -0,0 +1,8 @@
1
+ /** Label used when a ticket has no resolvable department/subject. */
2
+ export const UNCLASSIFIED = "ללא מחלקה";
3
+
4
+ /** Max execution time (ms) for ticket statistics aggregations. */
5
+ export const STATS_MAX_TIME_MS = 30_000;
6
+
7
+ /** Default look-back window (days) for ticket statistics date ranges. */
8
+ export const DEFAULT_LOOKBACK_DAYS = 30;
@@ -1,5 +1,4 @@
1
1
  import { CityName, getDb, ObjectId, Ticket } from "../index";
2
- import type { SubjectStatsItem } from "./tickets.types";
3
2
  import { Collection, Filter, ObjectId as MongoObjectId } from "mongodb";
4
3
 
5
4
  /**
@@ -68,145 +67,6 @@ export const deleteTicket = async (ticketId: string): Promise<boolean> => {
68
67
  return result.deletedCount > 0;
69
68
  };
70
69
 
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
-
210
70
  export const findTicketByQuery = async (query: Partial<Ticket>) => {
211
71
  return await getTicketsCollection().findOne(query);
212
72
  };
@@ -0,0 +1,113 @@
1
+ import { CityName } from "../utils/types";
2
+ import { STATS_MAX_TIME_MS, UNCLASSIFIED } from "./tickets.constants";
3
+ import { getTicketsCollection } from "./tickets.getters";
4
+ import type { TicketStatsDateRange } from "./tickets.types";
5
+
6
+ const effectiveSubjectIdExpr = {
7
+ $ifNull: [
8
+ "$externalCallFields.event_subject_id",
9
+ {
10
+ $ifNull: [
11
+ "$externalCallFields.event_sub_subject_id",
12
+ "$externalCallFields.event_sub_subject_id2",
13
+ ],
14
+ },
15
+ ],
16
+ };
17
+
18
+ const departmentsSubjectsLookup = (cityName: CityName) => ({
19
+ $lookup: {
20
+ from: "departmentsSubjects",
21
+ let: { sid: "$effectiveSubjectId", c: cityName },
22
+ pipeline: [
23
+ {
24
+ $match: {
25
+ $expr: {
26
+ $and: [
27
+ { $eq: ["$cityName", "$$c"] },
28
+ {
29
+ $or: [
30
+ { $eq: ["$subject_id", { $ifNull: ["$$sid", ""] }] },
31
+ { $eq: ["$sub_subject_id", { $ifNull: ["$$sid", ""] }] },
32
+ { $eq: ["$sub_subject_id2", { $ifNull: ["$$sid", ""] }] },
33
+ ],
34
+ },
35
+ ],
36
+ },
37
+ },
38
+ },
39
+ { $limit: 1 },
40
+ ],
41
+ as: "subj",
42
+ },
43
+ });
44
+
45
+ const subjectLabelExpr = {
46
+ $cond: {
47
+ if: { $gt: [{ $size: "$subj" }, 0] },
48
+ then: {
49
+ $ifNull: [{ $arrayElemAt: ["$subj.subjectName", 0] }, UNCLASSIFIED],
50
+ },
51
+ else: UNCLASSIFIED,
52
+ },
53
+ };
54
+
55
+ const ticketDateMatch = (dateRange: TicketStatsDateRange) => ({
56
+ createdAt: { $gte: dateRange.from, $lte: dateRange.to },
57
+ });
58
+
59
+ export const isDraftSubjectIdExpr = {
60
+ $or: [
61
+ { $eq: [{ $type: "$externalCallFields.event_subject_id" }, "missing"] },
62
+ { $eq: ["$externalCallFields.event_subject_id", null] },
63
+ {
64
+ $regexMatch: {
65
+ input: { $ifNull: ["$externalCallFields.event_subject_id", ""] },
66
+ regex: /^\s*$/,
67
+ },
68
+ },
69
+ ],
70
+ };
71
+
72
+ export const ticketsWithCallSidMatch = (
73
+ cityName: CityName,
74
+ dateRange: TicketStatsDateRange,
75
+ ) => ({
76
+ cityName,
77
+ callSid: { $exists: true, $nin: [null, ""] },
78
+ ...ticketDateMatch(dateRange),
79
+ });
80
+
81
+ export const ticketsByCallSidsMatch = (
82
+ cityName: CityName,
83
+ callSids: string[],
84
+ dateRange?: TicketStatsDateRange,
85
+ ) => ({
86
+ cityName,
87
+ callSid: { $in: callSids },
88
+ ...(dateRange ? ticketDateMatch(dateRange) : {}),
89
+ });
90
+
91
+ export const subjectGroupStages = (
92
+ cityName: CityName,
93
+ limit?: number,
94
+ ): Record<string, unknown>[] => {
95
+ const shouldLimit = typeof limit === "number" && limit > 0;
96
+ return [
97
+ { $addFields: { effectiveSubjectId: effectiveSubjectIdExpr } },
98
+ departmentsSubjectsLookup(cityName),
99
+ { $addFields: { subject: subjectLabelExpr } },
100
+ { $group: { _id: "$subject", count: { $sum: 1 } } },
101
+ { $sort: { count: -1 } },
102
+ ...(shouldLimit ? [{ $limit: limit }] : []),
103
+ ];
104
+ };
105
+
106
+ export const runTicketStatsAggregate = async <T>(
107
+ pipeline: Record<string, unknown>[],
108
+ ): Promise<T[]> => {
109
+ const rows = await getTicketsCollection()
110
+ .aggregate(pipeline, { maxTimeMS: STATS_MAX_TIME_MS })
111
+ .toArray();
112
+ return rows as T[];
113
+ };
@@ -0,0 +1,93 @@
1
+ import { CityName } from "../utils/types";
2
+ import {
3
+ DAY_MS,
4
+ endOfCalendarDayInTz,
5
+ startOfCalendarDayInTz,
6
+ } from "../../utils/date.utils";
7
+ import { DEFAULT_LOOKBACK_DAYS } from "./tickets.constants";
8
+ import {
9
+ isDraftSubjectIdExpr,
10
+ runTicketStatsAggregate,
11
+ subjectGroupStages,
12
+ ticketsByCallSidsMatch,
13
+ ticketsWithCallSidMatch,
14
+ } from "./tickets.statistics.aggregation";
15
+ import type {
16
+ SubjectItem,
17
+ TicketStatsDateRange,
18
+ TicketStatsDateScope,
19
+ } from "./tickets.types";
20
+
21
+ export const resolveTicketStatsDateRange = (
22
+ dateScope?: TicketStatsDateScope,
23
+ ): TicketStatsDateRange => {
24
+ const to = dateScope?.to ?? new Date();
25
+ const from =
26
+ dateScope?.from ?? new Date(to.getTime() - DEFAULT_LOOKBACK_DAYS * DAY_MS);
27
+ return { from, to };
28
+ };
29
+
30
+ export const ticketStatsDateScopeFromYmd = (
31
+ startStr: string,
32
+ endStr: string,
33
+ timezone: string,
34
+ ): TicketStatsDateScope => ({
35
+ from: startOfCalendarDayInTz(startStr, timezone),
36
+ to: endOfCalendarDayInTz(endStr, timezone),
37
+ });
38
+
39
+ export const findCallSidsWithTicketsByCity = async (
40
+ cityName: CityName,
41
+ dateScope?: TicketStatsDateScope,
42
+ ): Promise<string[]> => {
43
+ const rows = await runTicketStatsAggregate<{ _id: unknown }>([
44
+ { $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
45
+ { $group: { _id: "$callSid" } },
46
+ ]);
47
+ return rows.map((r) => String(r._id)).filter(Boolean);
48
+ };
49
+
50
+ export const findCallSidsWithDraftTicketsByCity = async (
51
+ cityName: CityName,
52
+ dateScope?: TicketStatsDateScope,
53
+ ): Promise<string[]> => {
54
+ const rows = await runTicketStatsAggregate<{ _id: unknown }>([
55
+ { $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
56
+ { $match: { $expr: isDraftSubjectIdExpr } },
57
+ { $group: { _id: "$callSid" } },
58
+ ]);
59
+ return rows.map((r) => String(r._id)).filter(Boolean);
60
+ };
61
+
62
+ export const findSubjectsByCityAndDateRange = async (
63
+ cityName: CityName,
64
+ dateScope?: TicketStatsDateScope,
65
+ limit?: number,
66
+ ): Promise<SubjectItem[]> => {
67
+ const rows = await runTicketStatsAggregate<{ _id: unknown; count: number }>([
68
+ { $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
69
+ ...subjectGroupStages(cityName, limit),
70
+ ]);
71
+ return rows.map((r) => ({ subject: String(r._id), count: r.count }));
72
+ };
73
+
74
+ export const findSubjectsByCallSids = async (
75
+ cityName: CityName,
76
+ callSids: string[],
77
+ limit?: number,
78
+ dateScope?: TicketStatsDateScope,
79
+ ): Promise<SubjectItem[]> => {
80
+ if (!callSids.length) return [];
81
+
82
+ const rows = await runTicketStatsAggregate<{ _id: unknown; count: number }>([
83
+ {
84
+ $match: ticketsByCallSidsMatch(
85
+ cityName,
86
+ callSids,
87
+ dateScope ? resolveTicketStatsDateRange(dateScope) : undefined,
88
+ ),
89
+ },
90
+ ...subjectGroupStages(cityName, limit),
91
+ ]);
92
+ return rows.map((r) => ({ subject: String(r._id), count: r.count }));
93
+ };
@@ -9,7 +9,6 @@ export type Ticket = {
9
9
  callSid?: string;
10
10
  cityName: CityName;
11
11
  externalCallFields: {
12
- // Request fields
13
12
  first_name?: string;
14
13
  last_name?: string;
15
14
  event_description?: string;
@@ -18,10 +17,9 @@ export type Ticket = {
18
17
  event_subject_id?: string;
19
18
  event_sub_subject_id?: string;
20
19
  event_sub_subject_id2?: string | null;
21
- // Response fields (only if external call was successful)
22
20
  call_number?: string;
23
- status?: number; // Response status from Bina API (1 = success)
24
- error?: string; // Error message from Bina API if any
21
+ status?: number;
22
+ error?: string;
25
23
  image1?: string;
26
24
  image2?: string;
27
25
  image3?: string;
@@ -34,10 +32,17 @@ export type Ticket = {
34
32
 
35
33
  export type TicketDoc = WithId<Ticket>;
36
34
 
37
- /** Call count by department (subject). */
38
- export type SubjectStatsItem = {
39
- subject_name: string;
40
- subject_id: string;
35
+ export type SubjectItem = {
36
+ subject: string;
41
37
  count: number;
42
- percentage: 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;
43
48
  };