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:
2026-01-22 13:10:33 -07:00
parent c23afdf0b7
commit 40cd0e35bf
2 changed files with 809 additions and 383 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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;