Compare commits
2 Commits
c23afdf0b7
...
fd56561aed
| Author | SHA1 | Date | |
|---|---|---|---|
| fd56561aed | |||
| 40cd0e35bf |
File diff suppressed because it is too large
Load Diff
619
routes/admin.js
619
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);
|
||||||
|
|
||||||
@ -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"' });
|
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
|
// Check if user exists
|
||||||
const existingUser = await findById(db, id);
|
const existingUser = await findById(db, id);
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
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
|
// Update user role
|
||||||
const updatedUser = await updateUserRole(db, id, 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 = router;
|
||||||
|
module.exports.clearStatsCache = clearStatsCache;
|
||||||
|
|||||||
@ -69,37 +69,68 @@ function shouldLog(action) {
|
|||||||
return allowedActions.includes(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
|
* 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 {Db} db - MongoDB database instance
|
||||||
* @param {string} userId - User ID (will be converted to ObjectId)
|
* @param {string|Object} userIdOrOptions - User ID string or options object
|
||||||
* @param {string} action - Action type (use ACTIONS constants)
|
* @param {string} [action] - Action type (use ACTIONS constants)
|
||||||
* @param {Object} metadata - Additional action-specific data
|
* @param {Object} [metadata] - Additional action-specific data
|
||||||
* @param {Object} req - Express request object (for IP and user agent)
|
* @param {Object} [req] - Express request object (for IP and user agent)
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function logActivity(db, userId, action, metadata = {}, req = null) {
|
async function logActivity(db, userIdOrOptions, action, metadata = {}, req = null) {
|
||||||
// Only log if this action type is enabled at current log level
|
let userId;
|
||||||
if (!shouldLog(action)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract IP address (handle proxy forwarding)
|
// Extract IP address (handle proxy forwarding)
|
||||||
let ip = null;
|
let ip = null;
|
||||||
if (req) {
|
if (actualReq) {
|
||||||
ip = req.ip || req.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
ip = actualReq.ip || actualReq.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user agent
|
// Extract user agent
|
||||||
const userAgent = req?.headers?.['user-agent'] || null;
|
const userAgent = actualReq?.headers?.['user-agent'] || null;
|
||||||
|
|
||||||
// Create activity document
|
// Create activity document
|
||||||
const activityDoc = {
|
const activityDoc = {
|
||||||
userId: new ObjectId(userId),
|
userId: new ObjectId(userId),
|
||||||
action: action,
|
action: actualAction,
|
||||||
metadata: metadata || {},
|
metadata: actualMetadata || {},
|
||||||
page: metadata?.page || null,
|
page: actualMetadata?.page || null,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
userAgent: userAgent,
|
userAgent: userAgent,
|
||||||
ip: ip
|
ip: ip
|
||||||
|
|||||||
Reference in New Issue
Block a user