Implement Phase 2 activity monitoring API
- Add GET /api/admin/activity with pagination and filters - Add GET /api/admin/users/:id/activity for user-specific history - Add GET /api/admin/activity/export for CSV export - Implement $lookup aggregation for user info (name, email) - Add date range filtering with validation - Export limited to 10,000 records with truncation headers - All 50 Phase 2 tests passing
This commit is contained in:
320
routes/admin.js
320
routes/admin.js
@ -8,7 +8,7 @@ const {
|
||||
updateUserRole,
|
||||
isValidObjectId
|
||||
} = require('../models/user');
|
||||
const { logActivity } = require('../services/activityLogger');
|
||||
const { logActivity, ACTIVITY_COLLECTION } = require('../services/activityLogger');
|
||||
|
||||
const router = express.Router({ strict: true });
|
||||
|
||||
@ -204,4 +204,322 @@ router.patch('/users/:id/role', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:id/activity
|
||||
* Returns paginated activity history for a specific user
|
||||
*/
|
||||
router.get('/users/:id/activity', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ObjectId format
|
||||
if (!isValidObjectId(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID format' });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await findById(db, id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Parse pagination parameters
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 50));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||
|
||||
// Build filter for this user
|
||||
const filter = { userId: new ObjectId(id) };
|
||||
|
||||
// Execute queries in parallel
|
||||
const [activities, total] = await Promise.all([
|
||||
collection.find(filter)
|
||||
.sort({ timestamp: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
collection.countDocuments(filter)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
activities,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit) || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user activity:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user activity' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Validate ISO date string
|
||||
*/
|
||||
function isValidDateString(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const date = new Date(dateStr);
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Escape CSV value
|
||||
*/
|
||||
function escapeCSVValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
const str = String(value);
|
||||
// If contains comma, newline, or double quote, wrap in quotes and escape quotes
|
||||
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/activity
|
||||
* Returns paginated activity stream with optional filters
|
||||
*/
|
||||
router.get('/activity', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { userId, action, startDate, endDate } = req.query;
|
||||
|
||||
// Parse and validate pagination parameters
|
||||
const page = parseInt(req.query.page);
|
||||
const limit = parseInt(req.query.limit);
|
||||
|
||||
// Validate page
|
||||
if (req.query.page !== undefined && (isNaN(page) || page < 1)) {
|
||||
return res.status(400).json({ error: 'Invalid page parameter. Must be a positive integer.' });
|
||||
}
|
||||
|
||||
// Validate limit
|
||||
if (req.query.limit !== undefined && (isNaN(limit) || limit < 1)) {
|
||||
return res.status(400).json({ error: 'Invalid limit parameter. Must be a positive integer.' });
|
||||
}
|
||||
|
||||
const validatedPage = Math.max(1, page || 1);
|
||||
const validatedLimit = Math.min(200, Math.max(1, limit || 50));
|
||||
const skip = (validatedPage - 1) * validatedLimit;
|
||||
|
||||
// Validate userId if provided
|
||||
if (userId && !isValidObjectId(userId)) {
|
||||
return res.status(400).json({ error: 'Invalid userId format' });
|
||||
}
|
||||
|
||||
// Validate dates if provided
|
||||
if (startDate && !isValidDateString(startDate)) {
|
||||
return res.status(400).json({ error: 'Invalid startDate format. Use ISO date string.' });
|
||||
}
|
||||
if (endDate && !isValidDateString(endDate)) {
|
||||
return res.status(400).json({ error: 'Invalid endDate format. Use ISO date string.' });
|
||||
}
|
||||
|
||||
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||
|
||||
// Build match filter
|
||||
const matchFilter = {};
|
||||
if (userId) {
|
||||
matchFilter.userId = new ObjectId(userId);
|
||||
}
|
||||
if (action) {
|
||||
matchFilter.action = action;
|
||||
}
|
||||
if (startDate || endDate) {
|
||||
matchFilter.timestamp = {};
|
||||
if (startDate) {
|
||||
matchFilter.timestamp.$gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
matchFilter.timestamp.$lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Count total matching documents
|
||||
const total = await collection.countDocuments(matchFilter);
|
||||
|
||||
// Aggregate with $lookup to get user info
|
||||
const pipeline = [
|
||||
{ $match: matchFilter },
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: validatedLimit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo'
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
userName: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.name', 0] }, null]
|
||||
},
|
||||
userEmail: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.email', 0] }, null]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
userInfo: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const activities = await collection.aggregate(pipeline).toArray();
|
||||
|
||||
res.json({
|
||||
activities,
|
||||
pagination: {
|
||||
page: validatedPage,
|
||||
limit: validatedLimit,
|
||||
total,
|
||||
pages: Math.ceil(total / validatedLimit) || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/activity/export
|
||||
* Export activity logs as CSV file
|
||||
*/
|
||||
router.get('/activity/export', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { userId, action, startDate, endDate } = req.query;
|
||||
|
||||
// Validate required parameters
|
||||
if (!startDate) {
|
||||
return res.status(400).json({ error: 'startDate is required' });
|
||||
}
|
||||
if (!endDate) {
|
||||
return res.status(400).json({ error: 'endDate is required' });
|
||||
}
|
||||
|
||||
// Validate date formats
|
||||
if (!isValidDateString(startDate)) {
|
||||
return res.status(400).json({ error: 'Invalid startDate format. Use ISO date string.' });
|
||||
}
|
||||
if (!isValidDateString(endDate)) {
|
||||
return res.status(400).json({ error: 'Invalid endDate format. Use ISO date string.' });
|
||||
}
|
||||
|
||||
const parsedStartDate = new Date(startDate);
|
||||
const parsedEndDate = new Date(endDate);
|
||||
|
||||
// Validate startDate is before endDate
|
||||
if (parsedStartDate > parsedEndDate) {
|
||||
return res.status(400).json({ error: 'startDate must be before endDate' });
|
||||
}
|
||||
|
||||
// Validate userId if provided
|
||||
if (userId && !isValidObjectId(userId)) {
|
||||
return res.status(400).json({ error: 'Invalid userId format' });
|
||||
}
|
||||
|
||||
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||
const EXPORT_LIMIT = 10000;
|
||||
|
||||
// Build match filter
|
||||
const matchFilter = {
|
||||
timestamp: {
|
||||
$gte: parsedStartDate,
|
||||
$lte: parsedEndDate
|
||||
}
|
||||
};
|
||||
if (userId) {
|
||||
matchFilter.userId = new ObjectId(userId);
|
||||
}
|
||||
if (action) {
|
||||
matchFilter.action = action;
|
||||
}
|
||||
|
||||
// Get total count for truncation header
|
||||
const totalCount = await collection.countDocuments(matchFilter);
|
||||
|
||||
// Aggregate with $lookup to get user info, limited to EXPORT_LIMIT
|
||||
const pipeline = [
|
||||
{ $match: matchFilter },
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{ $limit: EXPORT_LIMIT },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo'
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
userName: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.name', 0] }, null]
|
||||
},
|
||||
userEmail: {
|
||||
$ifNull: [{ $arrayElemAt: ['$userInfo.email', 0] }, null]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
userInfo: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const activities = await collection.aggregate(pipeline).toArray();
|
||||
|
||||
// Build CSV content
|
||||
const csvHeaders = ['Timestamp', 'User', 'Email', 'Action', 'Details', 'Session ID', 'IP', 'User Agent'];
|
||||
const csvRows = [csvHeaders.join(',')];
|
||||
|
||||
for (const activity of activities) {
|
||||
const row = [
|
||||
escapeCSVValue(activity.timestamp ? activity.timestamp.toISOString() : ''),
|
||||
escapeCSVValue(activity.userName || ''),
|
||||
escapeCSVValue(activity.userEmail || ''),
|
||||
escapeCSVValue(activity.action || ''),
|
||||
escapeCSVValue(activity.metadata ? JSON.stringify(activity.metadata) : ''),
|
||||
escapeCSVValue(activity.sessionId || ''),
|
||||
escapeCSVValue(activity.ip || ''),
|
||||
escapeCSVValue(activity.userAgent || '')
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
}
|
||||
|
||||
const csvContent = csvRows.join('\n');
|
||||
|
||||
// Set response headers
|
||||
const filename = `activity-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
// Add truncation headers if limit was exceeded
|
||||
if (totalCount > EXPORT_LIMIT) {
|
||||
res.setHeader('X-Truncated', 'true');
|
||||
res.setHeader('X-Total-Records', totalCount.toString());
|
||||
}
|
||||
|
||||
res.send(csvContent);
|
||||
} catch (error) {
|
||||
console.error('Error exporting activity:', error);
|
||||
res.status(500).json({ error: 'Failed to export activity' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user