Admin Dashboard Backend (Phases 1-4) #5

Merged
stephen merged 9 commits from admin into main 2026-01-22 14:11:43 -07:00
14 changed files with 9721 additions and 18 deletions
Showing only changes of commit dbd8d81668 - Show all commits

View File

@ -73,15 +73,37 @@ async function findById(db, id) {
* @param {Db} db - MongoDB database instance * @param {Db} db - MongoDB database instance
* @param {string|ObjectId} id - User's MongoDB _id * @param {string|ObjectId} id - User's MongoDB _id
* @param {boolean} isActive - New active status * @param {boolean} isActive - New active status
* @param {string|ObjectId|null} adminId - Admin performing the action (required when disabling)
* @returns {Promise<Object>} Update result * @returns {Promise<Object>} Update result
*/ */
async function setUserActive(db, id, isActive) { async function setUserActive(db, id, isActive, adminId = null) {
// Convert string to ObjectId if needed // Convert string to ObjectId if needed
const objectId = typeof id === 'string' ? new ObjectId(id) : id; const objectId = typeof id === 'string' ? new ObjectId(id) : id;
let updateDoc;
if (isActive) {
// Enabling: clear disabledAt and disabledBy
updateDoc = {
$set: { isActive: true, disabledAt: null, disabledBy: null }
};
} else {
// Disabling: set disabledAt and disabledBy
// Convert string to ObjectId if needed, keep ObjectId as-is
const adminObjectId = adminId && typeof adminId === 'string'
? new ObjectId(adminId)
: adminId;
updateDoc = {
$set: {
isActive: false,
disabledAt: new Date(),
disabledBy: adminObjectId
}
};
}
const result = await db.collection(USER_COLLECTION).findOneAndUpdate( const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
{ _id: objectId }, { _id: objectId },
{ $set: { isActive } }, updateDoc,
{ returnDocument: 'after' } { returnDocument: 'after' }
); );
@ -115,14 +137,117 @@ async function createIndexes(db) {
{ isActive: 1, lastLoginAt: -1 } { isActive: 1, lastLoginAt: -1 }
); );
// Index on role for admin queries
await collection.createIndex({ role: 1 });
// Compound index on isActive and role for filtered admin queries
await collection.createIndex({ isActive: 1, role: 1 });
// Index on createdAt for recent registrations
await collection.createIndex({ createdAt: -1 });
// Index on lastLoginAt for recently active users
await collection.createIndex({ lastLoginAt: -1 });
console.log('User collection indexes created successfully'); console.log('User collection indexes created successfully');
} }
/**
* Get paginated list of users with optional filtering and sorting
*
* @param {Db} db - MongoDB database instance
* @param {Object} options - Query options
* @param {number} options.page - Page number (1-indexed)
* @param {number} options.limit - Items per page (max 100)
* @param {string} options.search - Search term for name/email
* @param {string} options.status - Filter by status: 'all', 'active', 'disabled'
* @param {string} options.sort - Sort field: 'createdAt', 'lastLoginAt', 'loginCount'
* @param {string} options.order - Sort order: 'asc', 'desc'
* @returns {Promise<{users: Array, pagination: Object}>}
*/
async function getPaginatedUsers(db, options = {}) {
const page = Math.max(1, parseInt(options.page) || 1);
const limit = Math.min(100, Math.max(1, parseInt(options.limit) || 20));
const skip = (page - 1) * limit;
// Build filter
const filter = {};
if (options.search) {
const searchRegex = new RegExp(options.search, 'i');
filter.$or = [{ name: searchRegex }, { email: searchRegex }];
}
if (options.status === 'active') {
filter.isActive = true;
} else if (options.status === 'disabled') {
filter.isActive = false;
}
// Build sort
const sortField = ['createdAt', 'lastLoginAt', 'loginCount'].includes(options.sort)
? options.sort
: 'createdAt';
const sortOrder = options.order === 'asc' ? 1 : -1;
const sort = { [sortField]: sortOrder };
const collection = db.collection(USER_COLLECTION);
// Execute queries in parallel
const [users, total] = await Promise.all([
collection.find(filter).sort(sort).skip(skip).limit(limit).toArray(),
collection.countDocuments(filter)
]);
return {
users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
/**
* Update a user's role
*
* @param {Db} db - MongoDB database instance
* @param {string|ObjectId} id - User's MongoDB _id
* @param {string} role - New role ('user' or 'admin')
* @returns {Promise<Object|null>} Updated user or null
*/
async function updateUserRole(db, id, role) {
const objectId = typeof id === 'string' ? new ObjectId(id) : id;
const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
{ _id: objectId },
{ $set: { role } },
{ returnDocument: 'after' }
);
return result;
}
/**
* Check if a string is a valid MongoDB ObjectId
*
* @param {string} id - String to validate
* @returns {boolean} True if valid ObjectId format
*/
function isValidObjectId(id) {
if (typeof id !== 'string') return false;
if (!id || !id.trim()) return false;
return /^[0-9a-fA-F]{24}$/.test(id);
}
module.exports = { module.exports = {
USER_COLLECTION, USER_COLLECTION,
findOrCreateUser, findOrCreateUser,
findByGoogleId, findByGoogleId,
findById, findById,
setUserActive, setUserActive,
createIndexes createIndexes,
getPaginatedUsers,
updateUserRole,
isValidObjectId
}; };

207
routes/admin.js Normal file
View File

@ -0,0 +1,207 @@
const express = require('express');
const { ObjectId } = require('mongodb');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const {
findById,
setUserActive,
getPaginatedUsers,
updateUserRole,
isValidObjectId
} = require('../models/user');
const { logActivity } = require('../services/activityLogger');
const router = express.Router({ strict: true });
// All admin routes require authentication and admin role
router.use(requireAuth, requireAdmin);
// Handle trailing slash on /users/ as invalid (empty ID)
router.get('/users/', (req, res) => {
return res.status(400).json({ error: 'Invalid user ID format' });
});
/**
* GET /api/admin/users
* Returns paginated list of users with optional filtering and sorting
*/
router.get('/users', async (req, res) => {
try {
const db = req.app.locals.db;
const { page, limit, search, status, sort, order } = req.query;
const result = await getPaginatedUsers(db, {
page,
limit,
search,
status,
sort,
order
});
res.json(result);
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
/**
* GET /api/admin/users/:id
* Returns detailed user information including recent activity
*/
router.get('/users/:id', 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' });
}
const user = await findById(db, id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Fetch recent activity for this user
const recentActivity = await db.collection('user_activity')
.find({ userId: user._id.toString() })
.sort({ timestamp: -1 })
.limit(20)
.toArray();
res.json({
user: {
...user,
recentActivity
}
});
} catch (error) {
console.error('Error fetching user details:', error);
res.status(500).json({ error: 'Failed to fetch user details' });
}
});
/**
* PATCH /api/admin/users/:id
* Enable or disable a user account
*/
router.patch('/users/:id', async (req, res) => {
try {
const db = req.app.locals.db;
const { id } = req.params;
const { isActive } = req.body;
// Validate ObjectId format
if (!isValidObjectId(id)) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
// Check if isActive is provided
if (typeof isActive !== 'boolean') {
return res.status(400).json({ error: 'isActive field is required' });
}
// Check if admin is trying to disable themselves
if (req.user._id.toString() === id && !isActive) {
return res.status(400).json({ error: 'Cannot disable yourself' });
}
// Check if user exists
const existingUser = await findById(db, id);
if (!existingUser) {
return res.status(404).json({ error: 'User not found' });
}
// Update user status
const updatedUser = await setUserActive(db, id, isActive, req.user._id);
// Log admin action
await logActivity(db, {
userId: req.user._id.toString(),
action: isActive ? 'ADMIN_ENABLE_USER' : 'ADMIN_DISABLE_USER',
metadata: {
targetUserId: id,
targetUserEmail: existingUser.email
}
});
res.json({
user: updatedUser,
message: isActive ? 'User enabled successfully' : 'User disabled successfully'
});
} catch (error) {
console.error('Error updating user status:', error);
res.status(500).json({ error: 'Failed to update user status' });
}
});
/**
* PATCH /api/admin/users/:id/role
* Promote or demote a user (change role)
*/
router.patch('/users/:id/role', async (req, res) => {
try {
const db = req.app.locals.db;
const { id } = req.params;
const { role } = req.body;
// Validate ObjectId format
if (!isValidObjectId(id)) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
// Validate role is provided
if (!role) {
return res.status(400).json({ error: 'Role is required' });
}
// Validate role value
if (!['user', 'admin'].includes(role)) {
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' });
}
// Update user role
const updatedUser = await updateUserRole(db, id, role);
// Log admin action
const action = role === 'admin' ? 'ADMIN_PROMOTE_USER' : 'ADMIN_DEMOTE_USER';
await logActivity(db, {
userId: req.user._id.toString(),
action,
metadata: {
targetUserId: id,
targetUserEmail: existingUser.email,
previousRole: existingUser.role,
newRole: role
}
});
const message = role === 'admin'
? 'User promoted to admin successfully'
: 'User demoted to user successfully';
res.json({
user: updatedUser,
message
});
} catch (error) {
console.error('Error updating user role:', error);
res.status(500).json({ error: 'Failed to update user role' });
}
});
module.exports = router;

View File

@ -8,6 +8,7 @@ const { configurePassport } = require('./middleware/passport');
const { requireAuth } = require('./middleware/auth'); const { requireAuth } = require('./middleware/auth');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const activityRoutes = require('./routes/activity'); const activityRoutes = require('./routes/activity');
const adminRoutes = require('./routes/admin');
const { createIndexes } = require('./models/user'); const { createIndexes } = require('./models/user');
const { createActivityIndexes } = require('./services/activityLogger'); const { createActivityIndexes } = require('./services/activityLogger');
@ -146,6 +147,9 @@ app.use('/auth', authRoutes);
// Mount activity routes // Mount activity routes
app.use('/activity', activityRoutes); app.use('/activity', activityRoutes);
// Mount admin routes
app.use('/api/admin', adminRoutes);
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
@ -369,15 +373,18 @@ app.get('/available-units', requireAuth, async (req, res) => {
} }
} }
} }
}
}
}, },
priceStats: { {
$let: { $addFields: {
vars: { // Filter prices to only include those from availability start date onwards
prices: { $map: { input: "$all_prices", as: "p", in: "$$p.price" } } availabilityPrices: {
$filter: {
input: "$all_prices",
cond: { $gte: ["$$this.date_checked", "$availabilityCalc.startDate"] }
}
}, },
in: {
minPrice: { $min: "$$prices" },
maxPrice: { $max: "$$prices" },
priceHistory: { priceHistory: {
$slice: [ $slice: [
{ $sortArray: { input: "$all_prices", sortBy: { date_checked: -1 } } }, { $sortArray: { input: "$all_prices", sortBy: { date_checked: -1 } } },
@ -385,6 +392,18 @@ app.get('/available-units', requireAuth, async (req, res) => {
] ]
} }
} }
},
{
$addFields: {
priceStats: {
$let: {
vars: {
prices: { $map: { input: "$availabilityPrices", as: "p", in: "$$p.price" } }
},
in: {
minPrice: { $min: "$$prices" },
maxPrice: { $max: "$$prices" }
}
} }
} }
} }
@ -411,7 +430,7 @@ app.get('/available-units', requireAuth, async (req, res) => {
}, },
priceHistory: { priceHistory: {
$map: { $map: {
input: "$priceStats.priceHistory", input: "$priceHistory",
as: "ph", as: "ph",
in: { in: {
date: "$$ph.date_checked", date: "$$ph.date_checked",