import { getCallsCollection } from "./calls.getters"; import { HOURS_PER_DAY, STATS_MAX_TIME_MS, TREND_MONTH_BUCKET_MIN_MONTHS, TREND_WEEK_BUCKET_MIN_MONTHS, } from "./calls.constants"; import { addDaysToDateStr, DAY_MS, dateAtNoonUtc, formatYmdInTz, getWeekdayInTz, getYmdInTz, incrementYmd, parseYmd, toUtcDate, ymdToStr, } from "../../utils/date.utils"; import { findTicketSubjectRows, ticketStatsDateScopeFromYmd, } from "../../municipal/tickets/tickets.statistics.getters"; import type { CityName } from "../../municipal/utils/types"; import type { SubjectItem, TicketStatsDateScope, } from "../../municipal/tickets/tickets.types"; import type { CallRoutingFilter, CallsHourlyBucket, CallsRoutingAggregation, CallsStatsFilter, CallsSummaryAggregation, CallsTrendBucket, ResolvedTimeBucket, } from "./calls.statistics.types"; const emptySummary = (): CallsSummaryAggregation => ({ totalCalls: 0, avgDurationSeconds: 0, openTickets: 0, draftTickets: 0, callsWithTickets: 0, }); const emptyRouting = (): CallsRoutingAggregation => ({ aiHandled: 0, transferred: 0, hijacked: 0, }); const isTransferred = { $eq: ["$redirectedCall", true] }; const isHijacked = { $and: [ { $eq: ["$isConferenceCall", true] }, { $ne: ["$redirectedCall", true] }, ], }; const countIf = (condition: Record) => ({ $sum: { $cond: [condition, 1, 0] }, }); const needsTicketStatusFilter = ( filters?: CallsStatsFilter["ticketStatusFilters"], ) => { if (!filters?.length) return false; const hasOpened = filters.includes("opened"); const hasNotOpened = filters.includes("not_opened"); return hasOpened !== hasNotOpened; }; const routingMatch = (routing?: CallRoutingFilter[]) => { if (!routing?.length) return null; const clauses: Record[] = []; if (routing.includes("transferred")) clauses.push({ redirectedCall: true }); if (routing.includes("hijacked")) { clauses.push({ isConferenceCall: true, redirectedCall: { $ne: true } }); } if (routing.includes("ai_only")) { clauses.push({ redirectedCall: { $ne: true }, $or: [ { isConferenceCall: { $ne: true } }, { isConferenceCall: { $exists: false } }, ], }); } return clauses.length ? { $or: clauses } : null; }; const ticketStatusMatch = (filter: CallsStatsFilter) => { if (!needsTicketStatusFilter(filter.ticketStatusFilters)) return null; const sids = [...(filter.callSidTicketCounts?.keys() ?? [])]; const openedOnly = filter.ticketStatusFilters?.includes("opened"); return { callSid: openedOnly ? { $in: sids } : { $nin: sids } }; }; // "HH:mm" → minute-of-day (0-1439), for the per-call time-of-day filter. const parseMinuteOfDay = (time?: string): number | undefined => time == null ? undefined : Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5)); const hourMatch = ( filter: CallsStatsFilter, ): Record | null => { const from = parseMinuteOfDay(filter.hourFrom); const to = parseMinuteOfDay(filter.hourTo); if (from == null && to == null) return null; if (from != null && to != null && from > to) { return { $or: [ { minuteOfDay: { $gte: from }, dateLocal: { $lt: filter.endStr } }, { minuteOfDay: { $lt: to }, dateLocal: { $gt: filter.startStr } }, ], }; } const minuteOfDay: Record = {}; if (from != null) minuteOfDay.$gte = from; if (to != null) minuteOfDay.$lt = to; return { minuteOfDay }; }; const createdAtMatchForFilter = ( filter: CallsStatsFilter, ): { createdAt: { $gte: Date; $lte: Date } } => { const bounds = ticketStatsDateScopeFromYmd( filter.startStr, filter.endStr, filter.timezone, ); return { createdAt: { $gte: bounds.from!, $lte: bounds.to! } }; }; const buildCallsMatchStages = ( filter: CallsStatsFilter, ): Record[] => { const hourRange = hourMatch(filter); const routing = routingMatch(filter.callRouting); const ticketMatch = ticketStatusMatch(filter); return [ { $match: { clientId: filter.clientId, isIncomingCall: true, ...createdAtMatchForFilter(filter), }, }, { $addFields: { dateLocal: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: filter.timezone, }, }, hour: { $hour: { date: "$createdAt", timezone: filter.timezone } }, minuteOfDay: { $add: [ { $multiply: [ { $hour: { date: "$createdAt", timezone: filter.timezone } }, 60, ], }, { $minute: { date: "$createdAt", timezone: filter.timezone } }, ], }, }, }, { $match: { dateLocal: { $gte: filter.startStr, $lte: filter.endStr } } }, ...(hourRange ? [{ $match: hourRange }] : []), ...(routing ? [{ $match: routing }] : []), ...(ticketMatch ? [{ $match: ticketMatch }] : []), ]; }; const aggregateCalls = ( filter: CallsStatsFilter, ...tail: Record[] ) => getCallsCollection().aggregate([...buildCallsMatchStages(filter), ...tail], { maxTimeMS: STATS_MAX_TIME_MS, }); const trendBucketExpr = (bucket: ResolvedTimeBucket, timezone: string) => bucket === "day" ? { bucket: "$dateLocal" } : { bucket: { $dateToString: { format: "%Y-%m-%d", date: { $dateTrunc: { date: "$createdAt", unit: bucket, timezone }, }, timezone, }, }, }; const mapTrendRow = (row: { _id: unknown; totalCalls: number; openTickets: number; }): CallsTrendBucket => ({ bucket: String(row._id), totalCalls: row.totalCalls, openTickets: row.openTickets, }); export const rangeDurationDays = (startStr: string, endStr: string): number => { const start = toUtcDate(startStr).getTime(); const end = toUtcDate(endStr).getTime(); return Math.floor((end - start) / DAY_MS) + 1; }; /** * Number of distinct calendar months the [startStr, endStr] range spans, inclusive. * Counts month boundaries rather than 30-day periods: e.g. Jan 31 -> Feb 1 returns 2. */ export const rangeDurationMonths = ( startStr: string, endStr: string, ): number => { const [sy, sm] = parseYmd(startStr); const [ey, em] = parseYmd(endStr); return (ey - sy) * 12 + (em - sm) + 1; }; /** * Chooses how the client calls-over-time bar chart groups each column: * one bar per day, week, or calendar month. Short ranges stay daily; longer * ones switch to weekly or monthly so the chart does not show hundreds of bars. */ export const resolveTimeBucketFromRange = ( startStr: string, endStr: string, ): ResolvedTimeBucket => { const months = rangeDurationMonths(startStr, endStr); if (months > TREND_MONTH_BUCKET_MIN_MONTHS) return "month"; if (months > TREND_WEEK_BUCKET_MIN_MONTHS) return "week"; return "day"; }; const weekBucketKeyForDate = (dateStr: string, timezone: string): string => { const anchor = dateAtNoonUtc(dateStr); const weekStart = new Date( anchor.getTime() - getWeekdayInTz(anchor, timezone) * DAY_MS, ); return formatYmdInTz(weekStart, timezone); }; const formatWeekBucketRange = ( weekStartStr: string, clipStartStr: string, clipEndStr: string, ): string => { const weekEndStr = addDaysToDateStr(weekStartStr, 6); const start = weekStartStr < clipStartStr ? clipStartStr : weekStartStr; const end = weekEndStr > clipEndStr ? clipEndStr : weekEndStr; return `${start}/${end}`; }; const monthBucketKey = (y: number, m: number): string => `${y}-${String(m).padStart(2, "0")}-01`; const emptyTrendBucket = (bucket: string): CallsTrendBucket => ({ bucket, totalCalls: 0, openTickets: 0, }); const indexTrendRowsByBucket = (rows: CallsTrendBucket[]) => new Map(rows.map((row) => [row.bucket, row])); const fillDailyTrendBuckets = ( rows: CallsTrendBucket[], startStr: string, endStr: string, ): CallsTrendBucket[] => { const byBucket = indexTrendRowsByBucket(rows); const filled: CallsTrendBucket[] = []; for ( let cursor = startStr; cursor <= endStr; cursor = ymdToStr(...incrementYmd(...parseYmd(cursor))) ) { filled.push(byBucket.get(cursor) ?? emptyTrendBucket(cursor)); } return filled; }; const fillMonthlyTrendBuckets = ( rows: CallsTrendBucket[], startStr: string, endStr: string, timezone: string, ): CallsTrendBucket[] => { const byBucket = indexTrendRowsByBucket(rows); const startParts = getYmdInTz(dateAtNoonUtc(startStr), timezone); const endParts = getYmdInTz(dateAtNoonUtc(endStr), timezone); const filled: CallsTrendBucket[] = []; let { y, m } = startParts; while (y < endParts.y || (y === endParts.y && m <= endParts.m)) { const bucket = monthBucketKey(y, m); filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket)); m += 1; if (m > 12) { m = 1; y += 1; } } return filled; }; const fillWeeklyTrendBuckets = ( rows: CallsTrendBucket[], startStr: string, endStr: string, timezone: string, ): CallsTrendBucket[] => { const byBucket = indexTrendRowsByBucket(rows); const filled: CallsTrendBucket[] = []; const seen = new Set(); for ( let i = 0, days = rangeDurationDays(startStr, endStr); i < days; i += 1 ) { const bucket = formatWeekBucketRange( weekBucketKeyForDate(addDaysToDateStr(startStr, i), timezone), startStr, endStr, ); if (seen.has(bucket)) continue; seen.add(bucket); filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket)); } return filled; }; const fillTrendBuckets = ( rows: CallsTrendBucket[], bucket: ResolvedTimeBucket, startStr: string, endStr: string, timezone: string, ): CallsTrendBucket[] => { switch (bucket) { case "day": return fillDailyTrendBuckets(rows, startStr, endStr); case "week": return fillWeeklyTrendBuckets(rows, startStr, endStr, timezone); case "month": return fillMonthlyTrendBuckets(rows, startStr, endStr, timezone); default: { return fillDailyTrendBuckets(rows, startStr, endStr); } } }; const resolveHourRange = ( filter: CallsStatsFilter, ): { hourFrom: number; hourTo: number } => { const from = parseMinuteOfDay(filter.hourFrom); const to = parseMinuteOfDay(filter.hourTo); if (from == null && to == null) return { hourFrom: 0, hourTo: HOURS_PER_DAY - 1 }; return { hourFrom: from == null ? 0 : Math.floor(from / 60), // hourTo is exclusive: last populated bucket is the hour holding minute (to - 1). hourTo: to == null ? HOURS_PER_DAY - 1 : Math.floor((to - 1) / 60), }; }; const roundOneDecimal = (value: number): number => Math.round(value * 10) / 10; const toHourlyDistribution = ( rows: { _id: unknown; totalCalls: number }[], hourRange: { hourFrom: number; hourTo: number }, daysInRange: number, ): CallsHourlyBucket[] => { const counts = new Map(rows.map((r) => [Number(r._id), r.totalCalls])); const { hourFrom, hourTo } = hourRange; const buckets: CallsHourlyBucket[] = []; const pushHour = (hour: number) => { const totalCalls = counts.get(hour) ?? 0; buckets.push({ hour, totalCalls, avgCalls: roundOneDecimal(daysInRange > 0 ? totalCalls / daysInRange : 0), }); }; if (hourFrom <= hourTo) { for (let hour = hourFrom; hour <= hourTo; hour += 1) pushHour(hour); } else { // Wraps past midnight: hourFrom..23, then 0..hourTo. for (let hour = hourFrom; hour < HOURS_PER_DAY; hour += 1) pushHour(hour); for (let hour = 0; hour <= hourTo; hour += 1) pushHour(hour); } return buckets; }; export const aggregateCallsSummary = async ( filter: CallsStatsFilter, ): Promise => { const hasTickets = Boolean( filter.callSidTicketCounts?.size || filter.callSidDraftTicketCounts?.size, ); const hasStatusFilter = needsTicketStatusFilter(filter.ticketStatusFilters); if (!hasTickets && !hasStatusFilter) { const result = await aggregateCalls(filter, { $group: { _id: null, totalCalls: { $sum: 1 }, avgDurationSeconds: { $avg: "$callLength" }, }, }).next(); if (!result) return emptySummary(); return { totalCalls: result.totalCalls, avgDurationSeconds: roundOneDecimal(result.avgDurationSeconds ?? 0), openTickets: 0, draftTickets: 0, callsWithTickets: 0, }; } // Single pass: project per-call data, handle ticket status filter and counting in JS. // Avoids embedding callSidsWithTickets inside any MongoDB pipeline stage. const rows = await aggregateCalls( { ...filter, ticketStatusFilters: undefined }, { $project: { _id: 0, callSid: 1, callLength: 1 } }, ).toArray(); const ticketMap = filter.callSidTicketCounts ?? new Map(); const draftMap = filter.callSidDraftTicketCounts ?? new Map(); const openedOnly = hasStatusFilter && filter.ticketStatusFilters!.includes("opened"); const notOpenedOnly = hasStatusFilter && filter.ticketStatusFilters!.includes("not_opened"); let totalCalls = 0; let totalDuration = 0; let openTickets = 0; let draftTickets = 0; let callsWithTickets = 0; for (const row of rows) { const sid = row.callSid as string; const hasTicket = ticketMap.has(sid); if (openedOnly && !hasTicket) continue; if (notOpenedOnly && hasTicket) continue; totalCalls += 1; totalDuration += (row.callLength as number | undefined) ?? 0; openTickets += ticketMap.get(sid) ?? 0; if (ticketMap.has(sid)) callsWithTickets += 1; draftTickets += draftMap.get(sid) ?? 0; } if (!totalCalls) return emptySummary(); return { totalCalls, avgDurationSeconds: roundOneDecimal(totalDuration / totalCalls), openTickets, draftTickets, callsWithTickets, }; }; export const aggregateCallsTrend = async ( filter: CallsStatsFilter, bucket: ResolvedTimeBucket, ): Promise => { const ticketMap = filter.callSidTicketCounts?.size ? filter.callSidTicketCounts : null; if (ticketMap) { const rows = await aggregateCalls( { ...filter, ticketStatusFilters: undefined }, { $addFields: trendBucketExpr(bucket, filter.timezone) }, { $project: { _id: 0, bucket: 1, callSid: 1 } }, ).toArray(); const openedOnly = needsTicketStatusFilter(filter.ticketStatusFilters) && filter.ticketStatusFilters!.includes("opened"); const notOpenedOnly = needsTicketStatusFilter(filter.ticketStatusFilters) && filter.ticketStatusFilters!.includes("not_opened"); const byBucket = new Map< string, { totalCalls: number; openTickets: number } >(); for (const row of rows) { const sid = row.callSid as string; const hasTicket = ticketMap.has(sid); if (openedOnly && !hasTicket) continue; if (notOpenedOnly && hasTicket) continue; const label = bucket === "week" ? formatWeekBucketRange( String(row.bucket), filter.startStr, filter.endStr, ) : String(row.bucket); const cur = byBucket.get(label) ?? { totalCalls: 0, openTickets: 0 }; cur.totalCalls += 1; cur.openTickets += ticketMap.get(sid) ?? 0; byBucket.set(label, cur); } const mappedRows = [...byBucket.entries()].map(([label, counts]) => ({ bucket: label, ...counts, })); return fillTrendBuckets( mappedRows, bucket, filter.startStr, filter.endStr, filter.timezone, ); } const rows = await aggregateCalls( filter, { $addFields: trendBucketExpr(bucket, filter.timezone) }, { $group: { _id: "$bucket", totalCalls: { $sum: 1 } } }, { $sort: { _id: 1 } }, ).toArray(); const mappedRows = bucket === "week" ? rows.map((row) => ({ bucket: formatWeekBucketRange( String(row._id), filter.startStr, filter.endStr, ), totalCalls: row.totalCalls as number, openTickets: 0, })) : rows.map((row) => mapTrendRow({ _id: row._id, totalCalls: row.totalCalls as number, openTickets: 0, }), ); return fillTrendBuckets( mappedRows, bucket, filter.startStr, filter.endStr, filter.timezone, ); }; export const aggregateCallsHourlyByRange = async ( filter: CallsStatsFilter, ): Promise => { const rows = await aggregateCalls( filter, { $group: { _id: "$hour", totalCalls: { $sum: 1 } } }, { $sort: { _id: 1 } }, ).toArray(); return toHourlyDistribution( rows.map((r) => ({ _id: r._id, totalCalls: r.totalCalls as number })), resolveHourRange(filter), rangeDurationDays(filter.startStr, filter.endStr), ); }; export const aggregateCallsRouting = async ( filter: CallsStatsFilter, ): Promise => { const result = await aggregateCalls( filter, { $group: { _id: null, transferred: countIf(isTransferred), hijacked: countIf(isHijacked), totalCalls: { $sum: 1 }, }, }, { $addFields: { aiHandled: { $subtract: ["$totalCalls", { $add: ["$transferred", "$hijacked"] }], }, }, }, ).next(); if (!result) return emptyRouting(); return { aiHandled: result.aiHandled, transferred: result.transferred, hijacked: result.hijacked, }; }; export const findFilteredCallSids = async ( filter: CallsStatsFilter, ): Promise => { const rows = await aggregateCalls(filter, { $group: { _id: "$callSid" }, }).toArray(); return rows.map((r) => String(r._id)).filter(Boolean); }; /** * "Subjects" panel: ticket count per subject, for calls passing the filters. * Calls and tickets are in separate DBs, so we join in code — filtered call IDs * from calls, subjects from tickets. Returns the full list sorted most-common * first; callers slice if they want a top-N. */ export const findSubjectsByCityAndDateRange = async ( cityName: CityName, filter: CallsStatsFilter, dateScope: TicketStatsDateScope, ): Promise => { const filteredCallSids = new Set(await findFilteredCallSids(filter)); if (!filteredCallSids.size) return []; const rows = await findTicketSubjectRows(cityName, dateScope); const counts = new Map(); for (const row of rows) { if (!filteredCallSids.has(row.callSid)) continue; counts.set(row.subject, (counts.get(row.subject) ?? 0) + 1); } return [...counts.entries()] .map(([subject, count]) => ({ subject, count })) .sort((a, b) => b.count - a.count || a.subject.localeCompare(b.subject)); };