Admin Dashboard Backend (Phases 1-4) #5
File diff suppressed because it is too large
Load Diff
422
routes/admin.js
422
routes/admin.js
@ -12,6 +12,39 @@ const { logActivity, ACTIVITY_COLLECTION } = require('../services/activityLogger
|
||||
|
||||
const router = express.Router({ strict: true });
|
||||
|
||||
// Statistics cache (5-minute TTL)
|
||||
const statsCache = new Map();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get cached value if still valid
|
||||
* @param {string} key - Cache key
|
||||
* @returns {*} Cached data or null
|
||||
*/
|
||||
function getCached(key) {
|
||||
const cached = statsCache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache value
|
||||
* @param {string} key - Cache key
|
||||
* @param {*} data - Data to cache
|
||||
*/
|
||||
function setCache(key, data) {
|
||||
statsCache.set(key, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached statistics (for testing)
|
||||
*/
|
||||
function clearStatsCache() {
|
||||
statsCache.clear();
|
||||
}
|
||||
|
||||
// All admin routes require authentication and admin role
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
@ -522,4 +555,393 @@ router.get('/activity/export', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Statistics Endpoints (Phase 3)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats/overview
|
||||
* Returns summary metrics for the admin dashboard
|
||||
* Cached for 5 minutes
|
||||
*/
|
||||
router.get('/stats/overview', async (req, res) => {
|
||||
try {
|
||||
const cacheKey = 'stats:overview';
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
const db = req.app.locals.db;
|
||||
const usersCollection = db.collection('users');
|
||||
|
||||
// Calculate date boundaries
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekStart = new Date(todayStart);
|
||||
weekStart.setDate(weekStart.getDate() - 7);
|
||||
const monthStart = new Date(todayStart);
|
||||
monthStart.setDate(monthStart.getDate() - 30);
|
||||
|
||||
// Execute all queries in parallel
|
||||
const [
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
disabledUsers,
|
||||
newUsersToday,
|
||||
newUsersThisWeek,
|
||||
newUsersThisMonth,
|
||||
loginStats
|
||||
] = await Promise.all([
|
||||
usersCollection.countDocuments({}),
|
||||
usersCollection.countDocuments({ isActive: true }),
|
||||
usersCollection.countDocuments({ isActive: false }),
|
||||
usersCollection.countDocuments({ createdAt: { $gte: todayStart } }),
|
||||
usersCollection.countDocuments({ createdAt: { $gte: weekStart } }),
|
||||
usersCollection.countDocuments({ createdAt: { $gte: monthStart } }),
|
||||
usersCollection.aggregate([
|
||||
{ $group: { _id: null, totalLogins: { $sum: '$loginCount' } } }
|
||||
]).toArray()
|
||||
]);
|
||||
|
||||
const totalLogins = loginStats.length > 0 ? loginStats[0].totalLogins : 0;
|
||||
const avgLoginsPerUser = totalUsers > 0
|
||||
? Math.round((totalLogins / totalUsers) * 10) / 10
|
||||
: 0;
|
||||
|
||||
const result = {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
disabledUsers,
|
||||
newUsersToday,
|
||||
newUsersThisWeek,
|
||||
newUsersThisMonth,
|
||||
totalLogins,
|
||||
avgLoginsPerUser
|
||||
};
|
||||
|
||||
setCache(cacheKey, result);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats overview:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch statistics overview' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats/active-users
|
||||
* Returns time series data for active users
|
||||
* Query params: period ('daily' | 'weekly' | 'monthly'), days (default: 30)
|
||||
* Cached for 5 minutes (different cache key per query params)
|
||||
*/
|
||||
router.get('/stats/active-users', async (req, res) => {
|
||||
try {
|
||||
let { period, days } = req.query;
|
||||
|
||||
// Validate and default period
|
||||
const validPeriods = ['daily', 'weekly', 'monthly'];
|
||||
if (!period || !validPeriods.includes(period)) {
|
||||
period = 'daily';
|
||||
}
|
||||
|
||||
// Validate and default days
|
||||
days = parseInt(days) || 30;
|
||||
days = Math.max(1, Math.min(365, days));
|
||||
|
||||
const cacheKey = `stats:active-users:${period}:${days}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
const db = req.app.locals.db;
|
||||
const activityCollection = db.collection(ACTIVITY_COLLECTION);
|
||||
|
||||
// Calculate the start date based on period and days
|
||||
const now = new Date();
|
||||
const startDate = new Date(now);
|
||||
|
||||
// Determine date grouping based on period
|
||||
let dateFormat;
|
||||
let dataPoints;
|
||||
|
||||
switch (period) {
|
||||
case 'weekly':
|
||||
// Each data point represents a week
|
||||
startDate.setDate(startDate.getDate() - (days * 7));
|
||||
dataPoints = days;
|
||||
dateFormat = {
|
||||
year: { $year: '$timestamp' },
|
||||
week: { $isoWeek: '$timestamp' }
|
||||
};
|
||||
break;
|
||||
case 'monthly':
|
||||
// Each data point represents a month
|
||||
startDate.setMonth(startDate.getMonth() - days);
|
||||
dataPoints = days;
|
||||
dateFormat = {
|
||||
year: { $year: '$timestamp' },
|
||||
month: { $month: '$timestamp' }
|
||||
};
|
||||
break;
|
||||
case 'daily':
|
||||
default:
|
||||
// Each data point represents a day
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
dataPoints = days;
|
||||
dateFormat = {
|
||||
year: { $year: '$timestamp' },
|
||||
month: { $month: '$timestamp' },
|
||||
day: { $dayOfMonth: '$timestamp' }
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregate unique users per period
|
||||
const pipeline = [
|
||||
{
|
||||
$match: {
|
||||
timestamp: { $gte: startDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: dateFormat,
|
||||
users: { $addToSet: '$userId' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
count: { $size: '$users' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { '_id.year': -1, '_id.month': -1, '_id.day': -1, '_id.week': -1 }
|
||||
},
|
||||
{
|
||||
$limit: dataPoints
|
||||
}
|
||||
];
|
||||
|
||||
const rawData = await activityCollection.aggregate(pipeline).toArray();
|
||||
|
||||
// Format the data into consistent date strings
|
||||
const data = rawData.map(item => {
|
||||
let date;
|
||||
if (period === 'daily') {
|
||||
date = `${item._id.year}-${String(item._id.month).padStart(2, '0')}-${String(item._id.day).padStart(2, '0')}`;
|
||||
} else if (period === 'weekly') {
|
||||
// Format as year-Wweek
|
||||
date = `${item._id.year}-W${String(item._id.week).padStart(2, '0')}`;
|
||||
} else if (period === 'monthly') {
|
||||
date = `${item._id.year}-${String(item._id.month).padStart(2, '0')}`;
|
||||
}
|
||||
return { date, count: item.count };
|
||||
});
|
||||
|
||||
// Sort by date descending (most recent first)
|
||||
data.sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
const result = { period, data };
|
||||
setCache(cacheKey, result);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching active users stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch active users statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats/actions
|
||||
* Returns action breakdown with counts
|
||||
* Query params: startDate, endDate (optional, ISO date strings)
|
||||
* Results sorted by count descending
|
||||
* Cached for 5 minutes
|
||||
*/
|
||||
router.get('/stats/actions', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
// Build cache key including date filters
|
||||
const cacheKey = `stats:actions:${startDate || 'all'}:${endDate || 'all'}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
const db = req.app.locals.db;
|
||||
const activityCollection = db.collection(ACTIVITY_COLLECTION);
|
||||
|
||||
// Build match filter
|
||||
const matchFilter = {};
|
||||
if (startDate || endDate) {
|
||||
matchFilter.timestamp = {};
|
||||
if (startDate && isValidDateString(startDate)) {
|
||||
matchFilter.timestamp.$gte = new Date(startDate);
|
||||
}
|
||||
if (endDate && isValidDateString(endDate)) {
|
||||
matchFilter.timestamp.$lte = new Date(endDate);
|
||||
}
|
||||
// Remove timestamp filter if it's empty
|
||||
if (Object.keys(matchFilter.timestamp).length === 0) {
|
||||
delete matchFilter.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate action counts
|
||||
const pipeline = [
|
||||
...(Object.keys(matchFilter).length > 0 ? [{ $match: matchFilter }] : []),
|
||||
{
|
||||
$group: {
|
||||
_id: '$action',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
action: '$_id',
|
||||
count: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { count: -1 }
|
||||
}
|
||||
];
|
||||
|
||||
const actions = await activityCollection.aggregate(pipeline).toArray();
|
||||
|
||||
const result = { actions };
|
||||
setCache(cacheKey, result);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching action stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch action statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats/peak-times
|
||||
* Returns hourly (0-23) and daily (Sunday-Saturday) activity averages
|
||||
* Based on last 30 days of data
|
||||
* Cached for 5 minutes
|
||||
*/
|
||||
router.get('/stats/peak-times', async (req, res) => {
|
||||
try {
|
||||
const cacheKey = 'stats:peak-times';
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
const db = req.app.locals.db;
|
||||
const activityCollection = db.collection(ACTIVITY_COLLECTION);
|
||||
|
||||
// Calculate 30 days ago
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// Aggregate hourly counts
|
||||
const hourlyPipeline = [
|
||||
{
|
||||
$match: {
|
||||
timestamp: { $gte: thirtyDaysAgo }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { $hour: '$timestamp' },
|
||||
totalActivity: { $sum: 1 },
|
||||
uniqueDays: { $addToSet: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$timestamp' }
|
||||
}}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
hour: '$_id',
|
||||
avgActivity: {
|
||||
$round: [
|
||||
{ $divide: ['$totalActivity', { $size: '$uniqueDays' }] },
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { hour: 1 }
|
||||
}
|
||||
];
|
||||
|
||||
// Aggregate daily counts (by day of week)
|
||||
const dailyPipeline = [
|
||||
{
|
||||
$match: {
|
||||
timestamp: { $gte: thirtyDaysAgo }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { $dayOfWeek: '$timestamp' }, // 1 = Sunday, 7 = Saturday
|
||||
totalActivity: { $sum: 1 },
|
||||
uniqueWeeks: { $addToSet: {
|
||||
$isoWeek: '$timestamp'
|
||||
}}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
dayNum: '$_id',
|
||||
avgActivity: {
|
||||
$round: [
|
||||
{ $divide: ['$totalActivity', { $max: [{ $size: '$uniqueWeeks' }, 1] }] },
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { dayNum: 1 }
|
||||
}
|
||||
];
|
||||
|
||||
const [hourlyRaw, dailyRaw] = await Promise.all([
|
||||
activityCollection.aggregate(hourlyPipeline).toArray(),
|
||||
activityCollection.aggregate(dailyPipeline).toArray()
|
||||
]);
|
||||
|
||||
// Day of week names (MongoDB uses 1=Sunday, 2=Monday, etc.)
|
||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
// Build complete hourly array (0-23)
|
||||
const hourlyMap = new Map(hourlyRaw.map(h => [h.hour, h.avgActivity]));
|
||||
const hourly = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
hourly.push({
|
||||
hour,
|
||||
avgActivity: hourlyMap.get(hour) || 0
|
||||
});
|
||||
}
|
||||
|
||||
// Build complete daily array (Sunday-Saturday)
|
||||
const dailyMap = new Map(dailyRaw.map(d => [d.dayNum, d.avgActivity]));
|
||||
const daily = [];
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
daily.push({
|
||||
day: dayNames[i - 1],
|
||||
avgActivity: dailyMap.get(i) || 0
|
||||
});
|
||||
}
|
||||
|
||||
const result = { hourly, daily };
|
||||
setCache(cacheKey, result);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching peak times stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch peak times statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.clearStatsCache = clearStatsCache;
|
||||
|
||||
Reference in New Issue
Block a user