diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5c68ef8..0c7ecdf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ -## Team Number : Team +## Team Number : Team ## Description diff --git a/prisma/migrations/20260228000000_add_role_rbac/migration.sql b/prisma/migrations/20260228000000_add_role_rbac/migration.sql new file mode 100644 index 0000000..5c6973a --- /dev/null +++ b/prisma/migrations/20260228000000_add_role_rbac/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f54567d..9e4f95c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ model User { email String @unique username String @unique password String + role Role @default(USER) passwordResetTokenHash String? passwordResetTokenExpiry DateTime? leetcodeUsername String? @@ -39,13 +40,9 @@ model User { // Relations - - dailySubmissions DailySubmission[] @relation("UserDailySubmissions") - ownedChallenges Challenge[] @relation("ChallengeOwner") - memberships ChallengeMember[] - challengeInvites ChallengeInvite[] @relation("InviteCreator") + ownedChallenges Challenge[] @relation("ChallengeOwner") + memberships ChallengeMember[] - @@map("users") } @@ -67,9 +64,7 @@ model Challenge { dailySubmissions DailySubmission[] @relation("ChallengeDailySubmissions") members ChallengeMember[] dailyResults DailyResult[] - invites ChallengeInvite[] - @@map("challenges") } @@ -152,33 +147,9 @@ model ProblemMetadata { @@map("problem_metadata") } -model ChallengeInvite { - id String @id @default(uuid()) - challengeId String - code String @unique - createdBy String - expiresAt DateTime - maxUses Int @default(1) - usedCount Int @default(0) - createdAt DateTime @default(now()) - - // Relations - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - creator User @relation("InviteCreator", fields: [createdBy], references: [id]) - - @@index([code]) - @@index([challengeId]) - @@map("challenge_invites") -} - -model TokenBlacklist { - id String @id @default(uuid()) - token String @unique - expiresAt DateTime - createdAt DateTime @default(now()) - - @@index([expiresAt]) - @@map("token_blacklist") +enum Role { + USER + ADMIN } enum ChallengeStatus { diff --git a/prisma/seed.js b/prisma/seed.js index e720a75..55dcb49 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -4,6 +4,14 @@ const bcryptjs = require("bcryptjs"); const prisma = new PrismaClient(); // Sample data +const ADMIN_USER = { + email: "admin@codeduel.dev", + username: "admin", + password: "Admin@1234", + leetcodeUsername: null, + role: "ADMIN", +}; + const USERS = [ { email: "john.doe@example.com", @@ -168,6 +176,26 @@ async function main() { // 1. Create Users console.log("šŸ‘„ Creating users..."); + + // Create admin user first + const adminHashedPassword = await bcryptjs.hash(ADMIN_USER.password, 10); + const adminUser = await prisma.user.create({ + data: { + email: ADMIN_USER.email, + username: ADMIN_USER.username, + password: adminHashedPassword, + leetcodeUsername: ADMIN_USER.leetcodeUsername, + role: ADMIN_USER.role, + emailPreferences: { + welcomeEmail: true, + streakReminder: true, + streakBroken: true, + weeklySummary: true, + }, + }, + }); + console.log(`āœ… Created admin user: ${adminUser.username}`); + const users = await Promise.all( USERS.map(async (user) => { const hashedPassword = await bcryptjs.hash(user.password, 10); @@ -392,8 +420,9 @@ async function main() { console.log(` • Penalty Ledger Entries: ${penaltyLedgerEntries.length}`); console.log("\nšŸ“ Sample Credentials for Testing:"); + console.log(` šŸ”‘ ADMIN: ${ADMIN_USER.username} / ${ADMIN_USER.password}`); users.forEach((user, index) => { - console.log(` ${index + 1}. ${user.username} / ${user.password}`); + console.log(` ${index + 1}. ${user.username} / ${USERS[index].password}`); }); } catch (error) { console.error("āŒ Error during seeding:", error); diff --git a/src/app.js b/src/app.js index ee58cdf..dbba36a 100644 --- a/src/app.js +++ b/src/app.js @@ -17,6 +17,7 @@ const authRoutes = require("./routes/auth.routes"); const challengeRoutes = require("./routes/challenge.routes"); const dashboardRoutes = require("./routes/dashboard.routes"); const leetcodeRoutes = require("./routes/leetcode.routes"); +const adminRoutes = require("./routes/admin.routes"); const { apiLimiter, authLimiter } = require('./middlewares/rateLimiter.middleware'); // Import security middlewares @@ -89,6 +90,7 @@ const createApp = () => { app.use("/api/challenges", challengeRoutes); app.use("/api/dashboard", dashboardRoutes); app.use("/api/leetcode", leetcodeRoutes); + app.use("/api/admin", adminRoutes); // Root endpoint app.get("/", (req, res) => { diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js index f39de56..9dd903c 100644 --- a/src/controllers/admin.controller.js +++ b/src/controllers/admin.controller.js @@ -1,14 +1,417 @@ +const { prisma } = require("../config/prisma"); const { asyncHandler } = require("../middlewares/error.middleware"); -const { getSubmissionAnalytics } = require("../services/admin.service"); +const { AppError } = require("../middlewares/error.middleware"); +const logger = require("../utils/logger"); -const analyticsDashboard = asyncHandler(async (req, res) => { - const analytics = await getSubmissionAnalytics(); +/** + * Get all users (admin only) + * GET /api/admin/users + */ +const getAllUsers = asyncHandler(async (req, res) => { + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + username: true, + role: true, + leetcodeUsername: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + ownedChallenges: true, + memberships: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + res.status(200).json({ + success: true, + data: users, + }); +}); + +/** + * Update a user's role (admin only) + * PATCH /api/admin/users/:id/role + */ +const updateUserRole = asyncHandler(async (req, res) => { + const { id } = req.params; + const { role } = req.body; + + const validRoles = ["USER", "ADMIN"]; + if (!role || !validRoles.includes(role)) { + throw new AppError(`Role must be one of: ${validRoles.join(", ")}`, 400); + } + + // Prevent admin from demoting themselves + if (id === req.user.id && role !== "ADMIN") { + throw new AppError("Admins cannot demote themselves", 403); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new AppError("User not found", 404); + } + + const updated = await prisma.user.update({ + where: { id }, + data: { role }, + select: { + id: true, + email: true, + username: true, + role: true, + updatedAt: true, + }, + }); + + logger.info( + `Admin ${req.user.username} changed role of user ${updated.username} to ${role}` + ); + + res.status(200).json({ + success: true, + message: `User role updated to ${role}`, + data: updated, + }); +}); + +/** + * Delete a user (admin only) + * DELETE /api/admin/users/:id + */ +const deleteUser = asyncHandler(async (req, res) => { + const { id } = req.params; + + if (id === req.user.id) { + throw new AppError("Admins cannot delete their own account", 403); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new AppError("User not found", 404); + } + + await prisma.user.delete({ where: { id } }); + + logger.info( + `Admin ${req.user.username} deleted user ${user.username} (${user.email})` + ); + + res.status(200).json({ + success: true, + message: "User deleted successfully", + }); +}); + +/** + * Get all challenges (admin only – no ownership filter) + * GET /api/admin/challenges + */ +const getAllChallenges = asyncHandler(async (req, res) => { + const { status } = req.query; + + const where = {}; + if (status) { + where.status = status.toUpperCase(); + } + + const challenges = await prisma.challenge.findMany({ + where, + include: { + owner: { + select: { id: true, username: true, email: true }, + }, + _count: { + select: { members: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + res.status(200).json({ + success: true, + data: challenges, + }); +}); + +/** + * Delete any challenge (admin only) + * DELETE /api/admin/challenges/:id + */ +const deleteChallenge = asyncHandler(async (req, res) => { + const { id } = req.params; + + const challenge = await prisma.challenge.findUnique({ where: { id } }); + if (!challenge) { + throw new AppError("Challenge not found", 404); + } + + await prisma.challenge.delete({ where: { id } }); + + logger.info( + `Admin ${req.user.username} deleted challenge "${challenge.name}" (${id})` + ); + + res.status(200).json({ + success: true, + message: "Challenge deleted successfully", + }); +}); + +/** + * Get aggregated platform stats (admin only) + * GET /api/admin/stats + */ +const getPlatformStats = asyncHandler(async (req, res) => { + const [ + totalUsers, + totalChallenges, + activeChallenges, + totalMemberships, + adminCount, + ] = await Promise.all([ + prisma.user.count(), + prisma.challenge.count(), + prisma.challenge.count({ where: { status: "ACTIVE" } }), + prisma.challengeMember.count(), + prisma.user.count({ where: { role: "ADMIN" } }), + ]); + + res.status(200).json({ + success: true, + data: { + users: { + total: totalUsers, + admins: adminCount, + regular: totalUsers - adminCount, + }, + challenges: { + total: totalChallenges, + active: activeChallenges, + }, + memberships: { + total: totalMemberships, + }, + }, + }); +}); + +module.exports = { + getAllUsers, + updateUserRole, + deleteUser, + getAllChallenges, + deleteChallenge, + getPlatformStats, +}; + + +/** + * Get all users (admin only) + * GET /api/admin/users + */ +const getAllUsers = asyncHandler(async (req, res) => { + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + username: true, + role: true, + leetcodeUsername: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + ownedChallenges: true, + memberships: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + res.status(200).json({ + success: true, + data: users, + }); +}); + +/** + * Update a user's role (admin only) + * PATCH /api/admin/users/:id/role + */ +const updateUserRole = asyncHandler(async (req, res) => { + const { id } = req.params; + const { role } = req.body; + + const validRoles = ["USER", "ADMIN"]; + if (!role || !validRoles.includes(role)) { + throw new AppError(`Role must be one of: ${validRoles.join(", ")}`, 400); + } + + // Prevent admin from demoting themselves + if (id === req.user.id && role !== "ADMIN") { + throw new AppError("Admins cannot demote themselves", 403); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new AppError("User not found", 404); + } + + const updated = await prisma.user.update({ + where: { id }, + data: { role }, + select: { + id: true, + email: true, + username: true, + role: true, + updatedAt: true, + }, + }); + + logger.info( + `Admin ${req.user.username} changed role of user ${updated.username} to ${role}` + ); + + res.status(200).json({ + success: true, + message: `User role updated to ${role}`, + data: updated, + }); +}); + +/** + * Delete a user (admin only) + * DELETE /api/admin/users/:id + */ +const deleteUser = asyncHandler(async (req, res) => { + const { id } = req.params; + + if (id === req.user.id) { + throw new AppError("Admins cannot delete their own account", 403); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new AppError("User not found", 404); + } + + await prisma.user.delete({ where: { id } }); + + logger.info( + `Admin ${req.user.username} deleted user ${user.username} (${user.email})` + ); + + res.status(200).json({ + success: true, + message: "User deleted successfully", + }); +}); + +/** + * Get all challenges (admin only — no ownership filter) + * GET /api/admin/challenges + */ +const getAllChallenges = asyncHandler(async (req, res) => { + const { status } = req.query; + + const where = {}; + if (status) { + where.status = status.toUpperCase(); + } + + const challenges = await prisma.challenge.findMany({ + where, + include: { + owner: { + select: { id: true, username: true, email: true }, + }, + _count: { + select: { members: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + res.status(200).json({ + success: true, + data: challenges, + }); +}); + +/** + * Delete any challenge (admin only) + * DELETE /api/admin/challenges/:id + */ +const deleteChallenge = asyncHandler(async (req, res) => { + const { id } = req.params; + + const challenge = await prisma.challenge.findUnique({ where: { id } }); + if (!challenge) { + throw new AppError("Challenge not found", 404); + } + + await prisma.challenge.delete({ where: { id } }); + + logger.info( + `Admin ${req.user.username} deleted challenge "${challenge.name}" (${id})` + ); + + res.status(200).json({ + success: true, + message: "Challenge deleted successfully", + }); +}); + +/** + * Get aggregated platform stats (admin only) + * GET /api/admin/stats + */ +const getPlatformStats = asyncHandler(async (req, res) => { + const [ + totalUsers, + totalChallenges, + activeChallenges, + totalMemberships, + adminCount, + ] = await Promise.all([ + prisma.user.count(), + prisma.challenge.count(), + prisma.challenge.count({ where: { status: "ACTIVE" } }), + prisma.challengeMember.count(), + prisma.user.count({ where: { role: "ADMIN" } }), + ]); res.status(200).json({ success: true, - message: "Admin submission analytics", - data: analytics, + data: { + users: { + total: totalUsers, + admins: adminCount, + regular: totalUsers - adminCount, + }, + challenges: { + total: totalChallenges, + active: activeChallenges, + }, + memberships: { + total: totalMemberships, + }, + }, }); }); -module.exports = { analyticsDashboard }; \ No newline at end of file +module.exports = { + getAllUsers, + updateUserRole, + deleteUser, + getAllChallenges, + deleteChallenge, + getPlatformStats, +}; diff --git a/src/middlewares/auth.middleware.js b/src/middlewares/auth.middleware.js index e49a5c9..1a6df13 100644 --- a/src/middlewares/auth.middleware.js +++ b/src/middlewares/auth.middleware.js @@ -51,6 +51,7 @@ const authenticate = async (req, res, next) => { id: true, email: true, username: true, + role: true, leetcodeUsername: true, createdAt: true, }, @@ -76,6 +77,37 @@ const authenticate = async (req, res, next) => { } }; +/** + * Role-based access control middleware factory. + * Must be used after `authenticate`. + * @param {...string} roles - Allowed roles (e.g. 'ADMIN', 'USER') + */ +const requireRole = (...roles) => { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ + success: false, + message: "Authentication required", + }); + } + + if (!roles.includes(req.user.role)) { + return res.status(403).json({ + success: false, + message: "Access denied: insufficient permissions", + }); + } + + next(); + }; +}; + +/** + * Shorthand middleware that restricts access to ADMIN role only. + * Must be used after `authenticate`. + */ +const requireAdmin = requireRole("ADMIN"); + /** * Optional authentication middleware * Attaches user to request if token is valid, but doesn't require authentication @@ -95,6 +127,7 @@ const optionalAuthenticate = async (req, res, next) => { id: true, email: true, username: true, + role: true, leetcodeUsername: true, createdAt: true, }, @@ -135,4 +168,6 @@ module.exports = { authenticate, optionalAuthenticate, authorizeAdmin, + requireRole, + requireAdmin, }; diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index a437ef0..e4df3cf 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -1,9 +1,51 @@ const express = require("express"); const router = express.Router(); -const { analyticsDashboard } = require("../controllers/admin.controller"); -const { authenticate, authorizeAdmin } = require("../middlewares/auth.middleware"); +const adminController = require("../controllers/admin.controller"); +const { authenticate, requireAdmin } = require("../middlewares/auth.middleware"); -// Only admin can access -router.get("/analytics", authenticate, authorizeAdmin, analyticsDashboard); +// All admin routes require authentication AND admin role +router.use(authenticate, requireAdmin); + +/** + * @route GET /api/admin/stats + * @desc Get aggregated platform stats + * @access Admin + */ +router.get("/stats", adminController.getPlatformStats); + +/** + * @route GET /api/admin/users + * @desc Get all users + * @access Admin + */ +router.get("/users", adminController.getAllUsers); + +/** + * @route PATCH /api/admin/users/:id/role + * @desc Update a user's role + * @access Admin + */ +router.patch("/users/:id/role", adminController.updateUserRole); + +/** + * @route DELETE /api/admin/users/:id + * @desc Delete a user + * @access Admin + */ +router.delete("/users/:id", adminController.deleteUser); + +/** + * @route GET /api/admin/challenges + * @desc Get all challenges (unfiltered) + * @access Admin + */ +router.get("/challenges", adminController.getAllChallenges); + +/** + * @route DELETE /api/admin/challenges/:id + * @desc Delete any challenge + * @access Admin + */ +router.delete("/challenges/:id", adminController.deleteChallenge); module.exports = router; \ No newline at end of file diff --git a/src/services/auth.service.js b/src/services/auth.service.js index ee2b7f2..a9eae83 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -41,6 +41,14 @@ const register = async (userData) => { password: hashedPassword, leetcodeUsername: leetcodeUsername || null, }, + select: { + id: true, + email: true, + username: true, + role: true, + leetcodeUsername: true, + createdAt: true, + }, }); // Generate JWT token @@ -103,6 +111,7 @@ const login = async (emailOrUsername, password) => { id: user.id, email: user.email, username: user.username, + role: user.role, leetcodeUsername: user.leetcodeUsername, createdAt: user.createdAt, }, @@ -120,6 +129,7 @@ const getProfile = async (userId) => { id: true, email: true, username: true, + role: true, leetcodeUsername: true, createdAt: true, _count: {