320 lines
9.9 KiB
JavaScript
320 lines
9.9 KiB
JavaScript
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;
|
|
}
|
|
}
|