@talkpilot/core-db 1.3.1 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/README.md +30 -0
- package/dist/municipal/tickets/index.d.ts +2 -1
- package/dist/municipal/tickets/index.d.ts.map +1 -1
- package/dist/municipal/tickets/index.js +1 -0
- package/dist/municipal/tickets/index.js.map +1 -1
- package/dist/municipal/tickets/tickets.constants.d.ts +7 -0
- package/dist/municipal/tickets/tickets.constants.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.constants.js +10 -0
- package/dist/municipal/tickets/tickets.constants.js.map +1 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +12 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.js +131 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.js.map +1 -0
- package/dist/municipal/tickets/tickets.getters.d.ts +0 -11
- package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
- package/dist/municipal/tickets/tickets.getters.js +0 -128
- package/dist/municipal/tickets/tickets.getters.js.map +1 -1
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +35 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.js +86 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts +7 -0
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.dates.js +40 -0
- package/dist/municipal/tickets/tickets.statistics.dates.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts +8 -0
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.getters.js +44 -0
- package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +53 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.js +112 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts +7 -0
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.utils.js +40 -0
- package/dist/municipal/tickets/tickets.statistics.utils.js.map +1 -0
- package/dist/municipal/tickets/tickets.types.d.ts +14 -5
- package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.constants.d.ts +17 -0
- package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.constants.js +20 -0
- package/dist/talkpilot/calls/calls.constants.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts +28 -0
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.getters.js +424 -0
- package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +12 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js +37 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +17 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.js +33 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.types.d.ts +39 -0
- package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.types.js +3 -0
- package/dist/talkpilot/calls/calls.statistics.types.js.map +1 -0
- package/dist/talkpilot/calls/calls.types.d.ts +1 -2
- package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.types.js +0 -3
- package/dist/talkpilot/calls/calls.types.js.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +33 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js +131 -146
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +27 -6
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.d.ts +3 -0
- package/dist/talkpilot/calls/index.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.js +3 -0
- package/dist/talkpilot/calls/index.js.map +1 -1
- package/dist/utils/date.utils.d.ts +49 -0
- package/dist/utils/date.utils.d.ts.map +1 -0
- package/dist/utils/date.utils.js +103 -0
- package/dist/utils/date.utils.js.map +1 -0
- package/dist/utils/statistics.aggregation.d.ts +20 -0
- package/dist/utils/statistics.aggregation.d.ts.map +1 -0
- package/dist/utils/statistics.aggregation.js +43 -0
- package/dist/utils/statistics.aggregation.js.map +1 -0
- package/package.json +1 -1
- package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +1 -37
- package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +51 -0
- package/src/municipal/tickets/index.ts +2 -1
- package/src/municipal/tickets/tickets.constants.ts +8 -0
- package/src/municipal/tickets/tickets.getters.ts +0 -140
- package/src/municipal/tickets/tickets.statistics.aggregation.ts +96 -0
- package/src/municipal/tickets/tickets.statistics.getters.ts +71 -0
- package/src/municipal/tickets/tickets.types.ts +19 -9
- package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +8 -111
- package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +344 -0
- package/src/talkpilot/calls/calls.constants.ts +20 -0
- package/src/talkpilot/calls/calls.statistics.getters.ts +587 -0
- package/src/talkpilot/calls/calls.statistics.types.ts +44 -0
- package/src/talkpilot/calls/calls.types.ts +4 -2
- package/src/talkpilot/calls/dashboard/calls.dashboard.ts +148 -197
- package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +25 -12
- package/src/talkpilot/calls/index.ts +3 -0
- package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +7 -0
- package/src/utils/date.utils.ts +116 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared timezone-aware date helpers for statistics aggregations.
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.endOfCalendarDayInTz = exports.startOfCalendarDayInTz = exports.dateAtNoonUtc = exports.getWeekdayInTz = exports.getYmdInTz = exports.formatYmdInTz = exports.incrementYmd = exports.addDaysToDateStr = exports.formatDate = exports.toUtcDate = exports.ymdToStr = exports.parseYmd = exports.WEEKDAY = exports.DAY_MS = void 0;
|
|
7
|
+
/** Milliseconds in a single day. */
|
|
8
|
+
exports.DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
/** Maps a short weekday name (as produced by Intl) to its index (Sun = 0). */
|
|
10
|
+
exports.WEEKDAY = {
|
|
11
|
+
Sun: 0,
|
|
12
|
+
Mon: 1,
|
|
13
|
+
Tue: 2,
|
|
14
|
+
Wed: 3,
|
|
15
|
+
Thu: 4,
|
|
16
|
+
Fri: 5,
|
|
17
|
+
Sat: 6,
|
|
18
|
+
};
|
|
19
|
+
/** Parses a `YYYY-MM-DD` string into `[year, month, day]` numbers. */
|
|
20
|
+
const parseYmd = (dateStr) => {
|
|
21
|
+
const [y, m, d] = dateStr.split("-").map(Number);
|
|
22
|
+
return [y, m, d];
|
|
23
|
+
};
|
|
24
|
+
exports.parseYmd = parseYmd;
|
|
25
|
+
/** Builds a zero-padded `YYYY-MM-DD` string from numeric parts. */
|
|
26
|
+
const ymdToStr = (y, m, d) => `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
27
|
+
exports.ymdToStr = ymdToStr;
|
|
28
|
+
/** Parses a `YYYY-MM-DD` string as midnight UTC. */
|
|
29
|
+
const toUtcDate = (dateStr) => new Date(`${dateStr}T00:00:00.000Z`);
|
|
30
|
+
exports.toUtcDate = toUtcDate;
|
|
31
|
+
/** Formats a date as a `YYYY-MM-DD` string in UTC. */
|
|
32
|
+
const formatDate = (date) => date.toISOString().slice(0, 10);
|
|
33
|
+
exports.formatDate = formatDate;
|
|
34
|
+
/** Adds `days` to a `YYYY-MM-DD` string, returning the resulting `YYYY-MM-DD`. */
|
|
35
|
+
const addDaysToDateStr = (dateStr, days) => (0, exports.formatDate)(new Date((0, exports.toUtcDate)(dateStr).getTime() + days * exports.DAY_MS));
|
|
36
|
+
exports.addDaysToDateStr = addDaysToDateStr;
|
|
37
|
+
/** Next civil calendar day (proleptic Gregorian). Avoids DST 24h steps that can stall iteration. */
|
|
38
|
+
const incrementYmd = (y, m, d) => {
|
|
39
|
+
const next = new Date(Date.UTC(y, m - 1, d + 1));
|
|
40
|
+
return [next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate()];
|
|
41
|
+
};
|
|
42
|
+
exports.incrementYmd = incrementYmd;
|
|
43
|
+
/**
|
|
44
|
+
* Formats a date as the calendar day in the given timezone.
|
|
45
|
+
* `en-CA` yields ISO-8601 `YYYY-MM-DD`, which is lexicographically sortable
|
|
46
|
+
* (string compare == chronological compare) and matches MongoDB's `%Y-%m-%d`.
|
|
47
|
+
*/
|
|
48
|
+
const formatYmdInTz = (date, timezone) => new Intl.DateTimeFormat("en-CA", { timeZone: timezone }).format(date);
|
|
49
|
+
exports.formatYmdInTz = formatYmdInTz;
|
|
50
|
+
/**
|
|
51
|
+
* Returns the calendar day in the given timezone as numeric parts.
|
|
52
|
+
* Reads parts by `type` (not string order), so `en-US` is fine here.
|
|
53
|
+
*/
|
|
54
|
+
const getYmdInTz = (date, timezone) => {
|
|
55
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
|
|
56
|
+
timeZone: timezone,
|
|
57
|
+
year: "numeric",
|
|
58
|
+
month: "numeric",
|
|
59
|
+
day: "numeric",
|
|
60
|
+
})
|
|
61
|
+
.formatToParts(date)
|
|
62
|
+
.map((p) => [p.type, Number(p.value)]));
|
|
63
|
+
return { y: parts.year, m: parts.month, d: parts.day };
|
|
64
|
+
};
|
|
65
|
+
exports.getYmdInTz = getYmdInTz;
|
|
66
|
+
/** Weekday index (Sun = 0) of a date in the given timezone. */
|
|
67
|
+
const getWeekdayInTz = (date, timezone) => exports.WEEKDAY[new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short" }).format(date)] ?? 0;
|
|
68
|
+
exports.getWeekdayInTz = getWeekdayInTz;
|
|
69
|
+
/**
|
|
70
|
+
* Returns the given `YYYY-MM-DD` day at noon UTC — a stable anchor inside the
|
|
71
|
+
* calendar day, far from midnight so timezone/DST shifts can't cross a date boundary.
|
|
72
|
+
*/
|
|
73
|
+
const dateAtNoonUtc = (dateStr) => {
|
|
74
|
+
const [y, m, d] = (0, exports.parseYmd)(dateStr);
|
|
75
|
+
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
|
|
76
|
+
};
|
|
77
|
+
exports.dateAtNoonUtc = dateAtNoonUtc;
|
|
78
|
+
/**
|
|
79
|
+
* UTC instant at the start of the given `YYYY-MM-DD` calendar day in `timezone`.
|
|
80
|
+
* Binary-searches the boundary so it stays correct across DST transitions.
|
|
81
|
+
*/
|
|
82
|
+
const startOfCalendarDayInTz = (dateStr, timezone) => {
|
|
83
|
+
const anchor = (0, exports.dateAtNoonUtc)(dateStr).getTime();
|
|
84
|
+
let low = anchor - 2 * exports.DAY_MS;
|
|
85
|
+
let high = anchor + exports.DAY_MS;
|
|
86
|
+
while (high - low > 1 /* ms */) {
|
|
87
|
+
const mid = Math.floor((low + high) / 2);
|
|
88
|
+
if ((0, exports.formatYmdInTz)(new Date(mid), timezone) >= dateStr)
|
|
89
|
+
high = mid;
|
|
90
|
+
else
|
|
91
|
+
low = mid;
|
|
92
|
+
}
|
|
93
|
+
return new Date(high);
|
|
94
|
+
};
|
|
95
|
+
exports.startOfCalendarDayInTz = startOfCalendarDayInTz;
|
|
96
|
+
/** UTC instant at the last millisecond of the given calendar day in `timezone`. */
|
|
97
|
+
const endOfCalendarDayInTz = (dateStr, timezone) => {
|
|
98
|
+
const [y, m, d] = (0, exports.parseYmd)(dateStr);
|
|
99
|
+
const nextDay = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
|
|
100
|
+
return new Date((0, exports.startOfCalendarDayInTz)(nextDay, timezone).getTime() - 1);
|
|
101
|
+
};
|
|
102
|
+
exports.endOfCalendarDayInTz = endOfCalendarDayInTz;
|
|
103
|
+
//# sourceMappingURL=date.utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"date.utils.js","sourceRoot":"","sources":["../../src/utils/date.utils.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Default server-side cap for statistics aggregations (30s). */
|
|
2
|
+
export declare const DEFAULT_STATS_MAX_TIME_MS = 30000;
|
|
3
|
+
/** Log a warning when SID arrays exceed this size (BSON / pipeline pressure). */
|
|
4
|
+
export declare const MAX_RECOMMENDED_SID_ARRAY_SIZE = 10000;
|
|
5
|
+
export type StatisticsAggregationOptions = {
|
|
6
|
+
maxTimeMS?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare class StatisticsTimeoutError extends Error {
|
|
9
|
+
constructor(maxTimeMS: number);
|
|
10
|
+
}
|
|
11
|
+
export declare const resolveMaxTimeMS: (options?: StatisticsAggregationOptions) => number;
|
|
12
|
+
/** Builds a constant lookup object for O(1) membership checks via $getField. */
|
|
13
|
+
export declare const buildCallSidLookup: (callSids: string[]) => Record<string, true>;
|
|
14
|
+
/**
|
|
15
|
+
* $expr that is true when `$callSid` is in `callSids`.
|
|
16
|
+
* Prefer over `$in` for large arrays embedded in aggregation pipelines.
|
|
17
|
+
*/
|
|
18
|
+
export declare const callSidInSetExpr: (callSids: string[]) => Record<string, unknown>;
|
|
19
|
+
export declare const warnIfLargeSidArray: (label: string, callSids: string[]) => void;
|
|
20
|
+
//# sourceMappingURL=statistics.aggregation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statistics.aggregation.d.ts","sourceRoot":"","sources":["../../src/utils/statistics.aggregation.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAEhD,iFAAiF;AACjF,eAAO,MAAM,8BAA8B,QAAS,CAAC;AAErD,MAAM,MAAM,4BAA4B,GAAG;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,SAAS,EAAE,MAAM;CAI9B;AAED,eAAO,MAAM,gBAAgB,GAAI,UAAU,4BAA4B,KAAG,MACzB,CAAC;AAElD,gFAAgF;AAChF,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,KAAG,MAAM,CAAC,MAAM,EAAE,IAAI,CACK,CAAC;AAEjF;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,UAAU,MAAM,EAAE,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAW1E,CAAC;AAEH,eAAO,MAAM,mBAAmB,GAAI,OAAO,MAAM,EAAE,UAAU,MAAM,EAAE,KAAG,IAMvE,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.warnIfLargeSidArray = exports.callSidInSetExpr = exports.buildCallSidLookup = exports.resolveMaxTimeMS = exports.StatisticsTimeoutError = exports.MAX_RECOMMENDED_SID_ARRAY_SIZE = exports.DEFAULT_STATS_MAX_TIME_MS = void 0;
|
|
4
|
+
/** Default server-side cap for statistics aggregations (30s). */
|
|
5
|
+
exports.DEFAULT_STATS_MAX_TIME_MS = 30_000;
|
|
6
|
+
/** Log a warning when SID arrays exceed this size (BSON / pipeline pressure). */
|
|
7
|
+
exports.MAX_RECOMMENDED_SID_ARRAY_SIZE = 10_000;
|
|
8
|
+
class StatisticsTimeoutError extends Error {
|
|
9
|
+
constructor(maxTimeMS) {
|
|
10
|
+
super(`Statistics aggregation exceeded maxTimeMS (${maxTimeMS})`);
|
|
11
|
+
this.name = "StatisticsTimeoutError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
exports.StatisticsTimeoutError = StatisticsTimeoutError;
|
|
15
|
+
const resolveMaxTimeMS = (options) => options?.maxTimeMS ?? exports.DEFAULT_STATS_MAX_TIME_MS;
|
|
16
|
+
exports.resolveMaxTimeMS = resolveMaxTimeMS;
|
|
17
|
+
/** Builds a constant lookup object for O(1) membership checks via $getField. */
|
|
18
|
+
const buildCallSidLookup = (callSids) => Object.fromEntries(callSids.map((sid) => [sid, true]));
|
|
19
|
+
exports.buildCallSidLookup = buildCallSidLookup;
|
|
20
|
+
/**
|
|
21
|
+
* $expr that is true when `$callSid` is in `callSids`.
|
|
22
|
+
* Prefer over `$in` for large arrays embedded in aggregation pipelines.
|
|
23
|
+
*/
|
|
24
|
+
const callSidInSetExpr = (callSids) => ({
|
|
25
|
+
$eq: [
|
|
26
|
+
{
|
|
27
|
+
$getField: {
|
|
28
|
+
field: "$callSid",
|
|
29
|
+
input: (0, exports.buildCallSidLookup)(callSids),
|
|
30
|
+
default: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
true,
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
exports.callSidInSetExpr = callSidInSetExpr;
|
|
37
|
+
const warnIfLargeSidArray = (label, callSids) => {
|
|
38
|
+
if (callSids.length > exports.MAX_RECOMMENDED_SID_ARRAY_SIZE) {
|
|
39
|
+
console.warn(`[core-db statistics] ${label}: ${callSids.length} call SIDs exceeds recommended max (${exports.MAX_RECOMMENDED_SID_ARRAY_SIZE}). Narrow the date range or cache SIDs once per request.`);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
exports.warnIfLargeSidArray = warnIfLargeSidArray;
|
|
43
|
+
//# sourceMappingURL=statistics.aggregation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statistics.aggregation.js","sourceRoot":"","sources":["../../src/utils/statistics.aggregation.ts"],"names":[],"mappings":";;;AAAA,iEAAiE;AACpD,QAAA,yBAAyB,GAAG,MAAM,CAAC;AAEhD,iFAAiF;AACpE,QAAA,8BAA8B,GAAG,MAAM,CAAC;AAMrD,MAAa,sBAAuB,SAAQ,KAAK;IAC/C,YAAY,SAAiB;QAC3B,KAAK,CAAC,8CAA8C,SAAS,GAAG,CAAC,CAAC;QAClE,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAC;IACvC,CAAC;CACF;AALD,wDAKC;AAEM,MAAM,gBAAgB,GAAG,CAAC,OAAsC,EAAU,EAAE,CACjF,OAAO,EAAE,SAAS,IAAI,iCAAyB,CAAC;AADrC,QAAA,gBAAgB,oBACqB;AAElD,gFAAgF;AACzE,MAAM,kBAAkB,GAAG,CAAC,QAAkB,EAAwB,EAAE,CAC7E,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAyB,CAAC;AADpE,QAAA,kBAAkB,sBACkD;AAEjF;;;GAGG;AACI,MAAM,gBAAgB,GAAG,CAAC,QAAkB,EAA2B,EAAE,CAAC,CAAC;IAChF,GAAG,EAAE;QACH;YACE,SAAS,EAAE;gBACT,KAAK,EAAE,UAAU;gBACjB,KAAK,EAAE,IAAA,0BAAkB,EAAC,QAAQ,CAAC;gBACnC,OAAO,EAAE,KAAK;aACf;SACF;QACD,IAAI;KACL;CACF,CAAC,CAAC;AAXU,QAAA,gBAAgB,oBAW1B;AAEI,MAAM,mBAAmB,GAAG,CAAC,KAAa,EAAE,QAAkB,EAAQ,EAAE;IAC7E,IAAI,QAAQ,CAAC,MAAM,GAAG,sCAA8B,EAAE,CAAC;QACrD,OAAO,CAAC,IAAI,CACV,wBAAwB,KAAK,KAAK,QAAQ,CAAC,MAAM,uCAAuC,sCAA8B,0DAA0D,CACjL,CAAC;IACJ,CAAC;AACH,CAAC,CAAC;AANW,QAAA,mBAAmB,uBAM9B"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
findTicketsByCallSid,
|
|
3
|
-
getTicketsCountByCityAndDateRange,
|
|
4
|
-
} from "../tickets.getters";
|
|
1
|
+
import { findTicketsByCallSid } from "../tickets.getters";
|
|
5
2
|
import { setDb } from "../../index";
|
|
6
3
|
import { Db, Collection } from "mongodb";
|
|
7
4
|
import { createTicket } from "../../../test-utils/factories";
|
|
@@ -30,37 +27,4 @@ describe("tickets getters", () => {
|
|
|
30
27
|
expect(mockCollection.find).toHaveBeenCalledWith({ callSid: "123" });
|
|
31
28
|
expect(result[0].callSid).toBe("123");
|
|
32
29
|
});
|
|
33
|
-
|
|
34
|
-
it("getTicketsCountByCityAndDateRange should not count tickets without callSid", async () => {
|
|
35
|
-
const aggregateNext = jest.fn().mockResolvedValue({ n: 1 });
|
|
36
|
-
mockCollection = {
|
|
37
|
-
...mockCollection,
|
|
38
|
-
aggregate: jest.fn().mockReturnValue({
|
|
39
|
-
next: aggregateNext,
|
|
40
|
-
}),
|
|
41
|
-
};
|
|
42
|
-
mockDb = {
|
|
43
|
-
collection: jest.fn().mockReturnValue(mockCollection),
|
|
44
|
-
};
|
|
45
|
-
setDb(mockDb as Db);
|
|
46
|
-
|
|
47
|
-
const result = await getTicketsCountByCityAndDateRange(
|
|
48
|
-
"ashdod",
|
|
49
|
-
"2026-01-01",
|
|
50
|
-
"2026-01-31",
|
|
51
|
-
"Asia/Jerusalem",
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
expect(mockCollection.aggregate).toHaveBeenCalledWith(
|
|
55
|
-
expect.arrayContaining([
|
|
56
|
-
{
|
|
57
|
-
$match: {
|
|
58
|
-
cityName: "ashdod",
|
|
59
|
-
callSid: { $exists: true, $nin: [null, ""] },
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
]),
|
|
63
|
-
);
|
|
64
|
-
expect(result).toBe(1);
|
|
65
|
-
});
|
|
66
30
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findCallSidsWithDraftTicketsByCity,
|
|
3
|
+
findCallSidsWithTicketsByCity,
|
|
4
|
+
getTicketsCollection,
|
|
5
|
+
ticketStatsDateScopeFromYmd,
|
|
6
|
+
} from "../../../index";
|
|
7
|
+
import { createTicket } from "../../../test-utils/factories";
|
|
8
|
+
|
|
9
|
+
const CITY = "tests" as const;
|
|
10
|
+
const TZ = "Asia/Jerusalem";
|
|
11
|
+
const mayScope = () => ticketStatsDateScopeFromYmd("2026-05-01", "2026-05-31", TZ);
|
|
12
|
+
|
|
13
|
+
describe("tickets statistics getters", () => {
|
|
14
|
+
it("should scope callSid lookups to the provided createdAt range", async () => {
|
|
15
|
+
await getTicketsCollection().insertMany([
|
|
16
|
+
createTicket({
|
|
17
|
+
cityName: CITY,
|
|
18
|
+
callSid: "CA-in-range",
|
|
19
|
+
createdAt: new Date("2026-05-15T10:00:00.000Z"),
|
|
20
|
+
updatedAt: new Date("2026-05-15T10:00:00.000Z"),
|
|
21
|
+
}),
|
|
22
|
+
createTicket({
|
|
23
|
+
cityName: CITY,
|
|
24
|
+
callSid: "CA-out-of-range",
|
|
25
|
+
createdAt: new Date("2024-01-01T10:00:00.000Z"),
|
|
26
|
+
updatedAt: new Date("2024-01-01T10:00:00.000Z"),
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
expect(await findCallSidsWithTicketsByCity(CITY, mayScope())).toEqual(["CA-in-range"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should treat whitespace-only event_subject_id as draft", async () => {
|
|
33
|
+
await getTicketsCollection().insertMany([
|
|
34
|
+
createTicket({
|
|
35
|
+
cityName: CITY,
|
|
36
|
+
callSid: "CA-draft-whitespace",
|
|
37
|
+
createdAt: new Date("2026-05-15T10:00:00.000Z"),
|
|
38
|
+
updatedAt: new Date("2026-05-15T10:00:00.000Z"),
|
|
39
|
+
externalCallFields: { event_subject_id: " \t " },
|
|
40
|
+
}),
|
|
41
|
+
createTicket({
|
|
42
|
+
cityName: CITY,
|
|
43
|
+
callSid: "CA-not-draft",
|
|
44
|
+
createdAt: new Date("2026-05-16T10:00:00.000Z"),
|
|
45
|
+
updatedAt: new Date("2026-05-16T10:00:00.000Z"),
|
|
46
|
+
externalCallFields: { event_subject_id: "100" },
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
expect(await findCallSidsWithDraftTicketsByCity(CITY, mayScope())).toEqual(["CA-draft-whitespace"]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -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,96 @@
|
|
|
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 subjectResolutionStages = (
|
|
82
|
+
cityName: CityName,
|
|
83
|
+
): Record<string, unknown>[] => [
|
|
84
|
+
{ $addFields: { effectiveSubjectId: effectiveSubjectIdExpr } },
|
|
85
|
+
departmentsSubjectsLookup(cityName),
|
|
86
|
+
{ $addFields: { subject: subjectLabelExpr } },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const runTicketStatsAggregate = async <T>(
|
|
90
|
+
pipeline: Record<string, unknown>[],
|
|
91
|
+
): Promise<T[]> => {
|
|
92
|
+
const rows = await getTicketsCollection()
|
|
93
|
+
.aggregate(pipeline, { maxTimeMS: STATS_MAX_TIME_MS })
|
|
94
|
+
.toArray();
|
|
95
|
+
return rows as T[];
|
|
96
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
subjectResolutionStages,
|
|
12
|
+
ticketsWithCallSidMatch,
|
|
13
|
+
} from "./tickets.statistics.aggregation";
|
|
14
|
+
import type {
|
|
15
|
+
TicketStatsDateRange,
|
|
16
|
+
TicketStatsDateScope,
|
|
17
|
+
TicketSubjectRow,
|
|
18
|
+
} from "./tickets.types";
|
|
19
|
+
|
|
20
|
+
export const resolveTicketStatsDateRange = (
|
|
21
|
+
dateScope?: TicketStatsDateScope,
|
|
22
|
+
): TicketStatsDateRange => {
|
|
23
|
+
const to = dateScope?.to ?? new Date();
|
|
24
|
+
const from =
|
|
25
|
+
dateScope?.from ?? new Date(to.getTime() - DEFAULT_LOOKBACK_DAYS * DAY_MS);
|
|
26
|
+
return { from, to };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const ticketStatsDateScopeFromYmd = (
|
|
30
|
+
startStr: string,
|
|
31
|
+
endStr: string,
|
|
32
|
+
timezone: string,
|
|
33
|
+
): TicketStatsDateScope => ({
|
|
34
|
+
from: startOfCalendarDayInTz(startStr, timezone),
|
|
35
|
+
to: endOfCalendarDayInTz(endStr, timezone),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const findCallSidsWithTicketsByCity = async (
|
|
39
|
+
cityName: CityName,
|
|
40
|
+
dateScope?: TicketStatsDateScope,
|
|
41
|
+
): Promise<string[]> => {
|
|
42
|
+
const rows = await runTicketStatsAggregate<{ _id: unknown }>([
|
|
43
|
+
{ $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
|
|
44
|
+
{ $group: { _id: "$callSid" } },
|
|
45
|
+
]);
|
|
46
|
+
return rows.map((r) => String(r._id)).filter(Boolean);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const findCallSidsWithDraftTicketsByCity = async (
|
|
50
|
+
cityName: CityName,
|
|
51
|
+
dateScope?: TicketStatsDateScope,
|
|
52
|
+
): Promise<string[]> => {
|
|
53
|
+
const rows = await runTicketStatsAggregate<{ _id: unknown }>([
|
|
54
|
+
{ $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
|
|
55
|
+
{ $match: { $expr: isDraftSubjectIdExpr } },
|
|
56
|
+
{ $group: { _id: "$callSid" } },
|
|
57
|
+
]);
|
|
58
|
+
return rows.map((r) => String(r._id)).filter(Boolean);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const findTicketSubjectRows = async (
|
|
62
|
+
cityName: CityName,
|
|
63
|
+
dateScope?: TicketStatsDateScope,
|
|
64
|
+
): Promise<TicketSubjectRow[]> => {
|
|
65
|
+
const rows = await runTicketStatsAggregate<{ callSid: unknown; subject: unknown }>([
|
|
66
|
+
{ $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
|
|
67
|
+
...subjectResolutionStages(cityName),
|
|
68
|
+
{ $project: { _id: 0, callSid: 1, subject: 1 } },
|
|
69
|
+
]);
|
|
70
|
+
return rows.map((r) => ({ callSid: String(r.callSid), subject: String(r.subject) }));
|
|
71
|
+
};
|
|
@@ -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;
|
|
24
|
-
error?: string;
|
|
21
|
+
status?: number;
|
|
22
|
+
error?: string;
|
|
25
23
|
image1?: string;
|
|
26
24
|
image2?: string;
|
|
27
25
|
image3?: string;
|
|
@@ -34,10 +32,22 @@ export type Ticket = {
|
|
|
34
32
|
|
|
35
33
|
export type TicketDoc = WithId<Ticket>;
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
subject_name: string;
|
|
40
|
-
subject_id: string;
|
|
35
|
+
export type SubjectItem = {
|
|
36
|
+
subject: string;
|
|
41
37
|
count: number;
|
|
42
|
-
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type TicketSubjectRow = {
|
|
41
|
+
callSid: string;
|
|
42
|
+
subject: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type TicketStatsDateScope = {
|
|
46
|
+
from?: Date;
|
|
47
|
+
to?: Date;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type TicketStatsDateRange = {
|
|
51
|
+
from: Date;
|
|
52
|
+
to: Date;
|
|
43
53
|
};
|