Implement Phase 3 usage statistics API
- Add GET /api/admin/stats/overview (user counts, login stats) - Add GET /api/admin/stats/active-users (time series with period toggle) - Add GET /api/admin/stats/actions (action breakdown with date filter) - Add GET /api/admin/stats/peak-times (hourly/daily averages) - Implement 5-minute in-memory caching for expensive aggregations - All 57 Phase 3 tests passing
This commit is contained in:
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 });
|
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
|
// All admin routes require authentication and admin role
|
||||||
router.use(requireAuth, requireAdmin);
|
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 = router;
|
||||||
|
module.exports.clearStatsCache = clearStatsCache;
|
||||||
|
|||||||
Reference in New Issue
Block a user