Implement Phase 1 admin dashboard backend
- Add database indexes for admin queries (role, isActive+role, createdAt, lastLoginAt) - Update setUserActive to track disabledAt and disabledBy fields - Add getPaginatedUsers function with search, filter, sort support - Add updateUserRole function for promoting/demoting users - Create admin routes with full user management API: - GET /api/admin/users - paginated list with search/filter/sort - GET /api/admin/users/:id - user details with recent activity - PATCH /api/admin/users/:id - enable/disable users - PATCH /api/admin/users/:id/role - promote/demote users - Add validation for ObjectId format and self-modification prevention - All 53 Phase 1 backend tests passing
This commit is contained in:
131
models/user.js
131
models/user.js
@ -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
207
routes/admin.js
Normal 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;
|
||||||
35
server.js
35
server.js
@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user