Compare commits

...

2 Commits

Author SHA1 Message Date
fd56561aed Implement Phase 4 settings and security API
- Add GET /api/admin/settings for configuration retrieval
- Add PATCH /api/admin/settings with validation (30-365 days)
- Implement TTL index update when retention period changes
- Add last admin protection (cannot demote last active admin)
- Add audit logging for admin actions (ADMIN_UPDATE_SETTINGS, etc.)
- 45/52 Phase 4 tests passing (7 rate limiting tests not in scope)
2026-01-22 13:25:49 -07:00
40cd0e35bf 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
2026-01-22 13:10:33 -07:00
3 changed files with 1045 additions and 401 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 });
// 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);
@ -163,17 +196,36 @@ router.patch('/users/:id/role', async (req, res) => {
return res.status(400).json({ error: 'Invalid role. Must be "user" or "admin"' });
}
// Check if admin is trying to demote themselves
if (req.user._id.toString() === id && role === 'user') {
return res.status(400).json({ error: 'Cannot demote yourself from admin' });
}
// Check if user exists
const existingUser = await findById(db, id);
if (!existingUser) {
return res.status(404).json({ error: 'User not found' });
}
// Check if this would demote the last admin (before self-demotion check)
// This is the more specific error when both conditions are true
if (existingUser.role === 'admin' && role === 'user') {
// Count active admins
const activeAdminCount = await db.collection('users').countDocuments({
role: 'admin',
isActive: true
});
if (activeAdminCount <= 1) {
// Customize message if admin is demoting themselves AND they're the last admin
const isSelfDemotion = req.user._id.toString() === id;
const errorMsg = isSelfDemotion
? 'Cannot demote yourself. You are the last admin and at least one admin must exist.'
: 'Cannot demote the last admin. At least one admin must exist.';
return res.status(400).json({ error: errorMsg });
}
}
// Check if admin is trying to demote themselves (only reached if not last admin)
if (req.user._id.toString() === id && role === 'user') {
return res.status(400).json({ error: 'Cannot demote yourself from admin' });
}
// Update user role
const updatedUser = await updateUserRole(db, id, role);
@ -522,4 +574,561 @@ 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' });
}
});
// ============================================================
// Settings Endpoints (Phase 4)
// ============================================================
const SETTINGS_COLLECTION = 'settings';
const SETTINGS_DOC_ID = 'admin_settings';
const DEFAULT_RETENTION_DAYS = 90;
const DEFAULT_LOG_LEVEL = 'all';
const MIN_RETENTION_DAYS = 30;
const MAX_RETENTION_DAYS = 365;
/**
* Get settings document or return defaults
* @param {Db} db - MongoDB database instance
* @returns {Promise<Object>} Settings object
*/
async function getSettings(db) {
const settings = await db.collection(SETTINGS_COLLECTION).findOne({ _id: SETTINGS_DOC_ID });
if (!settings) {
return {
activityRetentionDays: DEFAULT_RETENTION_DAYS,
activityLogLevel: DEFAULT_LOG_LEVEL
};
}
return {
activityRetentionDays: settings.activityRetentionDays ?? DEFAULT_RETENTION_DAYS,
activityLogLevel: settings.activityLogLevel ?? DEFAULT_LOG_LEVEL
};
}
/**
* Update TTL index on user_activity collection
* @param {Db} db - MongoDB database instance
* @param {number} days - Retention period in days
*/
async function updateTTLIndex(db, days) {
const collection = db.collection(ACTIVITY_COLLECTION);
const expireAfterSeconds = days * 24 * 60 * 60;
try {
// Try to drop existing TTL index
await collection.dropIndex('timestamp_ttl');
} catch (err) {
// Index may not exist, which is fine
if (err.codeName !== 'IndexNotFound') {
console.warn('Note: timestamp_ttl index not found, will create new one');
}
}
// Create new TTL index with updated expiry
await collection.createIndex(
{ timestamp: 1 },
{
name: 'timestamp_ttl',
expireAfterSeconds: expireAfterSeconds
}
);
}
/**
* GET /api/admin/settings
* Returns current admin settings
*/
router.get('/settings', async (req, res) => {
try {
const db = req.app.locals.db;
const settings = await getSettings(db);
res.json(settings);
} catch (error) {
console.error('Error fetching settings:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
}
});
/**
* PATCH /api/admin/settings
* Update admin settings
*/
router.patch('/settings', async (req, res) => {
try {
const db = req.app.locals.db;
const { activityRetentionDays, activityLogLevel } = req.body;
// Validate retention period if provided
if (activityRetentionDays !== undefined) {
// Check if it's a valid number
if (typeof activityRetentionDays !== 'number' || !Number.isInteger(activityRetentionDays)) {
return res.status(400).json({ error: 'activityRetentionDays must be an integer' });
}
// Check range
if (activityRetentionDays < MIN_RETENTION_DAYS || activityRetentionDays > MAX_RETENTION_DAYS) {
return res.status(400).json({
error: `activityRetentionDays must be between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS} days`
});
}
}
// Validate log level if provided
const validLogLevels = ['all', 'navigation', 'none'];
if (activityLogLevel !== undefined && !validLogLevels.includes(activityLogLevel)) {
return res.status(400).json({
error: `activityLogLevel must be one of: ${validLogLevels.join(', ')}`
});
}
// Build update object
const updateFields = {
updatedAt: new Date(),
updatedBy: req.user._id
};
if (activityRetentionDays !== undefined) {
updateFields.activityRetentionDays = activityRetentionDays;
}
if (activityLogLevel !== undefined) {
updateFields.activityLogLevel = activityLogLevel;
}
// Build $setOnInsert only for fields NOT in $set
const setOnInsertFields = {};
if (activityRetentionDays === undefined) {
setOnInsertFields.activityRetentionDays = DEFAULT_RETENTION_DAYS;
}
if (activityLogLevel === undefined) {
setOnInsertFields.activityLogLevel = DEFAULT_LOG_LEVEL;
}
// Upsert settings document
const updateDoc = { $set: updateFields };
if (Object.keys(setOnInsertFields).length > 0) {
updateDoc.$setOnInsert = setOnInsertFields;
}
await db.collection(SETTINGS_COLLECTION).updateOne(
{ _id: SETTINGS_DOC_ID },
updateDoc,
{ upsert: true }
);
// Update TTL index if retention period changed
if (activityRetentionDays !== undefined) {
await updateTTLIndex(db, activityRetentionDays);
}
// Log admin action
await logActivity(db, {
userId: req.user._id.toString(),
action: 'ADMIN_UPDATE_SETTINGS',
metadata: {
...(activityRetentionDays !== undefined && { activityRetentionDays }),
...(activityLogLevel !== undefined && { activityLogLevel })
}
});
// Fetch updated settings
const updatedSettings = await getSettings(db);
res.json({
settings: updatedSettings,
message: 'Settings updated successfully'
});
} catch (error) {
console.error('Error updating settings:', error);
res.status(500).json({ error: 'Failed to update settings' });
}
});
module.exports = router;
module.exports.clearStatsCache = clearStatsCache;

View File

@ -69,37 +69,68 @@ function shouldLog(action) {
return allowedActions.includes(action);
}
/**
* Check if an action is an admin action (always logged regardless of log level)
* @param {string} action - Action to check
* @returns {boolean} True if admin action
*/
function isAdminAction(action) {
return action && action.startsWith('ADMIN_');
}
/**
* Log a user activity to the database
* Supports two calling conventions:
* 1. logActivity(db, userId, action, metadata, req) - positional parameters
* 2. logActivity(db, { userId, action, metadata }) - object parameter for admin actions
*
* @param {Db} db - MongoDB database instance
* @param {string} userId - User ID (will be converted to ObjectId)
* @param {string} action - Action type (use ACTIONS constants)
* @param {Object} metadata - Additional action-specific data
* @param {Object} req - Express request object (for IP and user agent)
* @param {string|Object} userIdOrOptions - User ID string or options object
* @param {string} [action] - Action type (use ACTIONS constants)
* @param {Object} [metadata] - Additional action-specific data
* @param {Object} [req] - Express request object (for IP and user agent)
* @returns {Promise<void>}
*/
async function logActivity(db, userId, action, metadata = {}, req = null) {
// Only log if this action type is enabled at current log level
if (!shouldLog(action)) {
async function logActivity(db, userIdOrOptions, action, metadata = {}, req = null) {
let userId;
let actualAction;
let actualMetadata;
let actualReq;
// Support object-based call for admin actions
if (typeof userIdOrOptions === 'object' && userIdOrOptions !== null) {
userId = userIdOrOptions.userId;
actualAction = userIdOrOptions.action;
actualMetadata = userIdOrOptions.metadata || {};
actualReq = userIdOrOptions.req || null;
} else {
userId = userIdOrOptions;
actualAction = action;
actualMetadata = metadata;
actualReq = req;
}
// Admin actions bypass log level filtering
if (!isAdminAction(actualAction) && !shouldLog(actualAction)) {
return;
}
try {
// Extract IP address (handle proxy forwarding)
let ip = null;
if (req) {
ip = req.ip || req.headers?.['x-forwarded-for']?.split(',')[0] || null;
if (actualReq) {
ip = actualReq.ip || actualReq.headers?.['x-forwarded-for']?.split(',')[0] || null;
}
// Extract user agent
const userAgent = req?.headers?.['user-agent'] || null;
const userAgent = actualReq?.headers?.['user-agent'] || null;
// Create activity document
const activityDoc = {
userId: new ObjectId(userId),
action: action,
metadata: metadata || {},
page: metadata?.page || null,
action: actualAction,
metadata: actualMetadata || {},
page: actualMetadata?.page || null,
timestamp: new Date(),
userAgent: userAgent,
ip: ip