export class DataManager { constructor(mongodb, redis, timeManager, options) { this.mongodb = mongodb; this.redis = redis; this.timeManager = timeManager; this.options = options || {}; } ensureDate(d) { if (d instanceof Date) return d; if (typeof d === 'string') return new Date(d); if (typeof d === 'number') return new Date(d); if (d && d.date) return new Date(d.date); // Handle MongoDB records console.error('Invalid date value:', d); return new Date(); // fallback to current date } async getData(dateRange, fetchFn) { try { // Get historical data from MongoDB const historicalData = await this.getHistoricalDays(dateRange.start, dateRange.end); // Find any missing date ranges const missingRanges = this.findMissingDateRanges(dateRange.start, dateRange.end, historicalData); // Fetch missing data for (const range of missingRanges) { const data = await fetchFn(range); await this.storeHistoricalPeriod(range.start, range.end, data); } // Get updated historical data const updatedData = await this.getHistoricalDays(dateRange.start, dateRange.end); // Handle both nested and flat data structures if (updatedData && updatedData.length > 0) { // Process each record and combine them const processedData = updatedData.map(record => { if (record.data) { return record.data; } if (record.total !== undefined) { return { total: record.total, by_direction: record.by_direction, by_status: record.by_status, by_missed_reason: record.by_missed_reason, by_hour: record.by_hour, by_users: record.by_users, daily_data: record.daily_data, duration_distribution: record.duration_distribution, average_duration: record.average_duration }; } return null; }).filter(Boolean); // Combine the data if (processedData.length > 0) { return this.combineMetrics(processedData); } } // Otherwise process as raw call data return this.processCallData(updatedData); } catch (error) { console.error('Error in getData:', error); throw error; } } findMissingDateRanges(start, end, existingDates) { const missingRanges = []; const existingDatesSet = new Set( existingDates.map((d) => { // Handle both nested and flat data structures const date = d.date ? d.date : d; return this.ensureDate(date).toISOString().split("T")[0]; }) ); let current = new Date(start); const endDate = new Date(end); while (current < endDate) { const dayBounds = this.timeManager.getDayBounds(current); const dayKey = dayBounds.start.toISOString().split("T")[0]; if (!existingDatesSet.has(dayKey)) { // Found a missing day const missingStart = new Date(dayBounds.start); const missingEnd = new Date(dayBounds.end); missingRanges.push({ start: missingStart, end: missingEnd, }); } // Move to the next day using timeManager to ensure proper business day boundaries current = new Date(dayBounds.end.getTime() + 1); } return missingRanges; } async getCurrentDay(fetchFn) { const now = new Date(); const todayBounds = this.timeManager.getDayBounds(now); const todayKey = this.timeManager.formatDate(todayBounds.start); const cacheKey = `${this.options.collection}:current_day:${todayKey}`; try { // Check cache first if (this.redis?.isOpen) { const cached = await this.redis.get(cacheKey); if (cached) { const parsedCache = JSON.parse(cached); if (parsedCache.total !== undefined) { // Use timeManager to check if the cached data is for today const cachedDate = new Date(parsedCache.daily_data[0].date); const isToday = this.timeManager.isToday(cachedDate); if (isToday) { return parsedCache; } } } } // Get safe end time that's never in the future const safeEnd = this.timeManager.getCurrentBusinessDayEnd(); // Fetch and process current day data with safe end time const data = await fetchFn({ start: todayBounds.start, end: safeEnd }); if (!data) { return null; } // Cache the data with a shorter TTL for today's data if (this.redis?.isOpen) { const ttl = Math.min( this.options.redisTTL, 60 * 5 // 5 minutes max for today's data ); await this.redis.set(cacheKey, JSON.stringify(data), { EX: ttl, }); } return data; } catch (error) { console.error('Error in getCurrentDay:', error); throw error; } } getDayCount(start, end) { // Calculate full days between dates using timeManager const startDay = this.timeManager.getDayBounds(start); const endDay = this.timeManager.getDayBounds(end); return Math.ceil((endDay.end - startDay.start) / (24 * 60 * 60 * 1000)); } async fetchMissingDays(start, end, existingData, fetchFn) { const existingDates = new Set( existingData.map((d) => this.timeManager.formatDate(d.date)) ); const missingData = []; let currentDate = new Date(start); while (currentDate < end) { const dayBounds = this.timeManager.getDayBounds(currentDate); const dateString = this.timeManager.formatDate(dayBounds.start); if (!existingDates.has(dateString)) { const data = await fetchFn({ start: dayBounds.start, end: dayBounds.end, }); await this.storeHistoricalDay(dayBounds.start, data); missingData.push(data); } // Move to next day using timeManager to ensure proper business day boundaries currentDate = new Date(dayBounds.end.getTime() + 1); } return missingData; } async getHistoricalDays(start, end) { try { if (!this.mongodb) return []; const collection = this.mongodb.collection(this.options.collection); const startDay = this.timeManager.getDayBounds(start); const endDay = this.timeManager.getDayBounds(end); const records = await collection .find({ date: { $gte: startDay.start, $lt: endDay.start, }, }) .sort({ date: 1 }) .toArray(); return records; } catch (error) { console.error('Error getting historical days:', error); return []; } } combineMetrics(metricsArray) { if (!metricsArray || metricsArray.length === 0) return null; if (metricsArray.length === 1) return metricsArray[0]; const combined = { total: 0, by_direction: { inbound: 0, outbound: 0 }, by_status: { answered: 0, missed: 0 }, by_missed_reason: {}, by_hour: Array(24).fill(0), by_users: {}, daily_data: [], duration_distribution: [ { range: '0-1m', count: 0 }, { range: '1-5m', count: 0 }, { range: '5-15m', count: 0 }, { range: '15-30m', count: 0 }, { range: '30m+', count: 0 } ], average_duration: 0 }; let totalAnswered = 0; let totalDuration = 0; metricsArray.forEach(metrics => { // Sum basic metrics combined.total += metrics.total; combined.by_direction.inbound += metrics.by_direction.inbound; combined.by_direction.outbound += metrics.by_direction.outbound; combined.by_status.answered += metrics.by_status.answered; combined.by_status.missed += metrics.by_status.missed; // Combine missed reasons Object.entries(metrics.by_missed_reason).forEach(([reason, count]) => { combined.by_missed_reason[reason] = (combined.by_missed_reason[reason] || 0) + count; }); // Sum hourly data metrics.by_hour.forEach((count, hour) => { combined.by_hour[hour] += count; }); // Combine user data Object.entries(metrics.by_users).forEach(([userId, userData]) => { if (!combined.by_users[userId]) { combined.by_users[userId] = { id: userData.id, name: userData.name, total: 0, answered: 0, missed: 0, total_duration: 0, average_duration: 0 }; } combined.by_users[userId].total += userData.total; combined.by_users[userId].answered += userData.answered; combined.by_users[userId].missed += userData.missed; combined.by_users[userId].total_duration += userData.total_duration || 0; }); // Combine duration distribution metrics.duration_distribution.forEach((dist, index) => { combined.duration_distribution[index].count += dist.count; }); // Accumulate for average duration calculation if (metrics.average_duration && metrics.by_status.answered) { totalDuration += metrics.average_duration * metrics.by_status.answered; totalAnswered += metrics.by_status.answered; } // Merge daily data if (metrics.daily_data) { combined.daily_data.push(...metrics.daily_data); } }); // Calculate final average duration if (totalAnswered > 0) { combined.average_duration = Math.round(totalDuration / totalAnswered); } // Calculate user averages Object.values(combined.by_users).forEach(user => { if (user.answered > 0) { user.average_duration = Math.round(user.total_duration / user.answered); } }); // Sort and deduplicate daily data combined.daily_data = Array.from( new Map(combined.daily_data.map(item => [item.date, item])).values() ).sort((a, b) => a.date.localeCompare(b.date)); return combined; } }