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:
2026-01-22 13:01:48 -07:00
parent dbd8d81668
commit c23afdf0b7
3 changed files with 1477 additions and 652 deletions

View File

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