diff --git a/.gitignore b/.gitignore index 0d57483..e69de29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,70 +0,0 @@ -# Dependencies -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Environment variables -.env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# Logs -logs/ -*.log - -# Runtime data -pids/ -*.pid -*.seed -*.pid.lock - -# Testing -coverage/ -.nyc_output/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Build output -dist/ -build/ - -# Prisma -prisma/migrations/ - -# Temporary files -tmp/ -temp/ - -# Lock files (use npm ci in CI/CD) -package-lock.json - -# Test files and scripts -test-*.js -setup.ps1 - -# Extra documentation (keep only README.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md) -BACKGROUND_JOBS.md -PR_DOCUMENTATION.md -QUICK_START.md -SETUP_CHECKLIST.md -SETUP_GUIDE.md -*.backup - -# Agent and skills files -.agents/ -skills-lock.json -# Agent/Skills files -.agents/ -skills-lock.json diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1e1e44..ab5f56d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" } @@ -9,7 +6,31 @@ datasource db { provider = "postgresql" } +model DailySubmission { + id String @id @default(uuid()) + userId String + challengeId String + date DateTime + total Int + passed Int + failed Int + + user User @relation(fields: [userId], references: [id], name: "UserDailySubmissions") + challenge Challenge @relation(fields: [challengeId], references: [id], name: "ChallengeDailySubmissions") + + @@index([challengeId, date]) + @@map("daily_submissions") +} + model User { + id String @id @default(uuid()) + email String @unique + username String @unique + password String + leetcodeUsername String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + id String @id @default(uuid()) email String @unique username String @unique @@ -23,87 +44,87 @@ model User { // Email Preferences emailPreferences Json? @default("{\"welcomeEmail\": true, \"streakReminder\": true, \"streakBroken\": true, \"weeklySummary\": true}") + // Relations + + dailySubmissions DailySubmission[] @relation("UserDailySubmissions") + ownedChallenges Challenge[] @relation("ChallengeOwner") + memberships ChallengeMember[] + + ownedChallenges Challenge[] @relation("ChallengeOwner") memberships ChallengeMember[] challengeInvites ChallengeInvite[] @relation("InviteCreator") + @@map("users") } model Challenge { - id String @id @default(uuid()) - name String - description String? - ownerId String - - // Challenge Rules - minSubmissionsPerDay Int @default(1) - difficultyFilter String[] // ["Easy", "Medium", "Hard"] - uniqueProblemConstraint Boolean @default(true) - penaltyAmount Float @default(0) - - // Challenge Timeline - startDate DateTime - endDate DateTime - status ChallengeStatus @default(PENDING) - visibility ChallengeVisibility @default(PUBLIC) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - + id String @id @default(uuid()) + name String + description String? + ownerId String + startDate DateTime + endDate DateTime + status ChallengeStatus @default(PENDING) + visibility ChallengeVisibility @default(PUBLIC) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + // Relations + + owner User @relation("ChallengeOwner", fields: [ownerId], references: [id], onDelete: Cascade) + dailySubmissions DailySubmission[] @relation("ChallengeDailySubmissions") + members ChallengeMember[] + owner User @relation("ChallengeOwner", fields: [ownerId], references: [id], onDelete: Cascade) members ChallengeMember[] dailyResults DailyResult[] invites ChallengeInvite[] + @@map("challenges") } model ChallengeMember { - id String @id @default(uuid()) - challengeId String - userId String - joinedAt DateTime @default(now()) - isActive Boolean @default(true) - - // Computed Stats - currentStreak Int @default(0) - longestStreak Int @default(0) - totalPenalties Float @default(0) - + id String @id @default(uuid()) + challengeId String + userId String + joinedAt DateTime @default(now()) + isActive Boolean @default(true) + + // Stats + currentStreak Int @default(0) + longestStreak Int @default(0) + totalPenalties Float @default(0) + // Relations - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - dailyResults DailyResult[] - penaltyLedger PenaltyLedger[] - + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + dailyResults DailyResult[] + penaltyLedger PenaltyLedger[] + @@unique([challengeId, userId]) @@map("challenge_members") } model DailyResult { - id String @id @default(uuid()) - challengeId String - memberId String - date DateTime @db.Date - - // Evaluation Results - completed Boolean @default(false) - submissionsCount Int @default(0) - problemsSolved String[] // Array of problem slugs - - // Metadata - evaluatedAt DateTime? - metadata Json? // Store additional data like submission details - - createdAt DateTime @default(now()) - + id String @id @default(uuid()) + challengeId String + memberId String + date DateTime @db.Date + completed Boolean @default(false) + submissionsCount Int @default(0) + problemsSolved String[] + evaluatedAt DateTime? + metadata Json? + createdAt DateTime @default(now()) + // Relations - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - member ChallengeMember @relation(fields: [memberId], references: [id], onDelete: Cascade) - + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + member ChallengeMember @relation(fields: [memberId], references: [id], onDelete: Cascade) + @@unique([challengeId, memberId, date]) @@index([date]) @@index([memberId]) @@ -111,38 +132,35 @@ model DailyResult { } model PenaltyLedger { - id String @id @default(uuid()) - memberId String - amount Float - reason String - date DateTime @db.Date - createdAt DateTime @default(now()) - - // Relations - member ChallengeMember @relation(fields: [memberId], references: [id], onDelete: Cascade) - + id String @id @default(uuid()) + memberId String + amount Float + reason String + date DateTime @db.Date + createdAt DateTime @default(now()) + + member ChallengeMember @relation(fields: [memberId], references: [id], onDelete: Cascade) + @@index([memberId]) @@index([date]) @@map("penalty_ledger") } model ProblemMetadata { - id String @id @default(uuid()) - titleSlug String @unique - questionId String - title String - difficulty String // Easy, Medium, Hard - acRate Float? - likes Int? - dislikes Int? - isPaidOnly Boolean @default(false) - topicTags String[] // Array of topic names - - // Cache metadata - lastFetchedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - + id String @id @default(uuid()) + titleSlug String @unique + questionId String + title String + difficulty String + acRate Float? + likes Int? + dislikes Int? + isPaidOnly Boolean @default(false) + topicTags String[] + lastFetchedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([titleSlug]) @@index([difficulty]) @@map("problem_metadata") @@ -187,4 +205,4 @@ enum ChallengeStatus { enum ChallengeVisibility { PUBLIC PRIVATE -} +} \ No newline at end of file diff --git a/src/app.js b/src/app.js index 82c0686..5995fa6 100644 --- a/src/app.js +++ b/src/app.js @@ -5,8 +5,12 @@ const responseTime = require("response-time"); const { config } = require("./config/env"); const { errorHandler, notFound } = require("./middlewares/error.middleware"); const logger = require("./utils/logger"); + +const adminRoutes = require("./routes/admin.routes"); + const requestLogger = require("./middlewares/requestLogger"); + // Import routes const authRoutes = require("./routes/auth.routes"); const challengeRoutes = require("./routes/challenge.routes"); @@ -26,7 +30,7 @@ const createApp = () => { // Apply strict limiting specifically to auth routes app.use('/api/auth/', authLimiter); - + app.use("/api/admin", adminRoutes); // 2. CORS configuration app.use( cors({ diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js new file mode 100644 index 0000000..f39de56 --- /dev/null +++ b/src/controllers/admin.controller.js @@ -0,0 +1,14 @@ +const { asyncHandler } = require("../middlewares/error.middleware"); +const { getSubmissionAnalytics } = require("../services/admin.service"); + +const analyticsDashboard = asyncHandler(async (req, res) => { + const analytics = await getSubmissionAnalytics(); + + res.status(200).json({ + success: true, + message: "Admin submission analytics", + data: analytics, + }); +}); + +module.exports = { analyticsDashboard }; \ No newline at end of file diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 57afcda..ec198e3 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -36,6 +36,9 @@ const validateLogin = [ ]; /** + + + * Validation middleware for forgot password */ const validateForgotPassword = [ @@ -53,11 +56,8 @@ const validateResetPassword = [ body("newPassword") .isLength({ min: 6 }) .withMessage("New password must be at least 6 characters"), -]; -/** - * Validation middleware for profile update - */ + const validateUpdateProfile = [ body("leetcodeUsername") .optional({ nullable: true }) @@ -265,6 +265,11 @@ module.exports = { login, getProfile, updateProfile, + + + validateRegister, + validateLogin, + forgotPassword, resetPassword, logout, @@ -273,5 +278,14 @@ module.exports = { validateForgotPassword, validateResetPassword, validateUpdateProfile, + + forgotPassword, + resetPassword, + validateRegister, + validateLogin, + validateForgotPassword, + validateResetPassword, + validateUpdateProfile, + }; diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js new file mode 100644 index 0000000..a437ef0 --- /dev/null +++ b/src/routes/admin.routes.js @@ -0,0 +1,9 @@ +const express = require("express"); +const router = express.Router(); +const { analyticsDashboard } = require("../controllers/admin.controller"); +const { authenticate, authorizeAdmin } = require("../middlewares/auth.middleware"); + +// Only admin can access +router.get("/analytics", authenticate, authorizeAdmin, analyticsDashboard); + +module.exports = router; \ No newline at end of file diff --git a/src/services/admin.service.js b/src/services/admin.service.js new file mode 100644 index 0000000..207a8ce --- /dev/null +++ b/src/services/admin.service.js @@ -0,0 +1,34 @@ +const { prisma } = require("../config/prisma"); + +const getSubmissionAnalytics = async () => { + // Submissions per day + const submissionsPerDay = await prisma.dailySubmission.groupBy({ + by: ["date"], + _sum: { total: true }, + orderBy: { date: "asc" }, + }); + + // Pass/fail rate per challenge + const passFailRate = await prisma.dailySubmission.groupBy({ + by: ["challengeId"], + _sum: { passed: true, failed: true, total: true }, + }); + + // Challenges with highest fail rates + const failRates = passFailRate + .map((c) => ({ + challengeId: c.challengeId, + failRate: c._sum.failed / c._sum.total, + totalSubmissions: c._sum.total, + })) + .sort((a, b) => b.failRate - a.failRate) + .slice(0, 5); // top 5 challenges + + return { + submissionsPerDay, + passFailRate, + highestFailChallenges: failRates, + }; +}; + +module.exports = { getSubmissionAnalytics }; \ No newline at end of file diff --git a/src/services/auth.service.js b/src/services/auth.service.js index 12a0d27..5acb26f 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -10,8 +10,6 @@ const { sendWelcomeEmail, sendPasswordResetEmail } = require("./email.service"); /** * Register a new user - * @param {Object} userData - User registration data - * @returns {Object} User object and JWT token */ const register = async (userData) => { const { email, username, password, leetcodeUsername } = userData; @@ -43,13 +41,6 @@ const register = async (userData) => { password: hashedPassword, leetcodeUsername: leetcodeUsername || null, }, - select: { - id: true, - email: true, - username: true, - leetcodeUsername: true, - createdAt: true, - }, }); // Generate JWT token @@ -64,22 +55,28 @@ const register = async (userData) => { }); return { - user, + user: { + id: user.id, + email: user.email, + username: user.username, + leetcodeUsername: user.leetcodeUsername, + createdAt: user.createdAt, + }, token, }; }; /** * Login user - * @param {string} emailOrUsername - Email or username - * @param {string} password - User password - * @returns {Object} User object and JWT token */ const login = async (emailOrUsername, password) => { - // Find user by email or username + // Find user by email OR username const user = await prisma.user.findFirst({ where: { - OR: [{ email: emailOrUsername }, { username: emailOrUsername }], + OR: [ + { email: emailOrUsername }, + { username: emailOrUsername }, + ], }, }); @@ -87,14 +84,14 @@ const login = async (emailOrUsername, password) => { throw new AppError("Invalid credentials", 401); } - // Verify password + // Compare password const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { throw new AppError("Invalid credentials", 401); } - // Generate JWT token + // Generate token const token = generateToken({ userId: user.id }); logger.info(`User logged in: ${user.username}`); @@ -115,8 +112,6 @@ const login = async (emailOrUsername, password) => { /** * Get user profile - * @param {string} userId - User ID - * @returns {Object} User profile */ const getProfile = async (userId) => { const user = await prisma.user.findUnique({ @@ -145,14 +140,11 @@ const getProfile = async (userId) => { /** * Update user profile - * @param {string} userId - User ID - * @param {Object} updateData - Data to update - * @returns {Object} Updated user profile */ const updateProfile = async (userId, updateData) => { const { leetcodeUsername, currentPassword, newPassword } = updateData; - // If changing password, verify current password + // If password change requested if (newPassword) { if (!currentPassword) { throw new AppError("Current password is required", 400); @@ -175,7 +167,6 @@ const updateProfile = async (userId, updateData) => { throw new AppError("Current password is incorrect", 401); } - // Hash new password const hashedPassword = await bcrypt.hash(newPassword, 12); const updatedUser = await prisma.user.update({ @@ -185,20 +176,23 @@ const updateProfile = async (userId, updateData) => { leetcodeUsername: leetcodeUsername !== undefined ? leetcodeUsername : undefined, }, - select: { - id: true, - email: true, - username: true, - leetcodeUsername: true, - updatedAt: true, - }, }); logger.info(`User profile updated: ${updatedUser.username}`); + + return { + id: updatedUser.id, + email: updatedUser.email, + username: updatedUser.username, + leetcodeUsername: updatedUser.leetcodeUsername, + updatedAt: updatedUser.updatedAt, + }; + await logAudit("PASSWORD_CHANGED", userId, { username: updatedUser.username }); return updatedUser; + } // Update without password change @@ -208,24 +202,128 @@ const updateProfile = async (userId, updateData) => { leetcodeUsername: leetcodeUsername !== undefined ? leetcodeUsername : undefined, }, + }); + + logger.info(`User profile updated: ${updatedUser.username}`); + + return { + id: updatedUser.id, + email: updatedUser.email, + username: updatedUser.username, + leetcodeUsername: updatedUser.leetcodeUsername, + updatedAt: updatedUser.updatedAt, + }; +}; + +/** + * Request password reset email + * @param {string} email - User email + * @returns {Object} Generic response + */ +const forgotPassword = async (email) => { + const user = await prisma.user.findUnique({ + where: { email }, select: { id: true, email: true, username: true, - leetcodeUsername: true, - updatedAt: true, }, }); - logger.info(`User profile updated: ${updatedUser.username}`); + if (!user) { + logger.info(`Password reset requested for non-existent email: ${email}`); + return { + message: + "If an account with that email exists, a password reset link has been sent.", + }; + } + + const rawToken = crypto.randomBytes(32).toString("hex"); + const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex"); + const expiryDate = new Date( + Date.now() + config.passwordResetTokenExpiryMinutes * 60 * 1000 + ); + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordResetTokenHash: tokenHash, + passwordResetTokenExpiry: expiryDate, + }, + }); + + const resetLink = `${config.appBaseUrl}/reset-password?token=${rawToken}`; + + const emailResult = await sendPasswordResetEmail( + user.email, + user.username, + resetLink, + config.passwordResetTokenExpiryMinutes + ); + + if (!emailResult.success) { + logger.error( + `Password reset email failed for ${user.email}: ${emailResult.reason}` + ); + } + + return { + message: + "If an account with that email exists, a password reset link has been sent.", + }; +}; + +/** + * Reset user password using token + * @param {string} token - Raw reset token + * @param {string} newPassword - New password + * @returns {Object} Success message + */ +const resetPassword = async (token, newPassword) => { + const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); + + const user = await prisma.user.findFirst({ + where: { + passwordResetTokenHash: tokenHash, + passwordResetTokenExpiry: { + gt: new Date(), + }, + }, + select: { + id: true, + username: true, + }, + }); + + if (!user) { + throw new AppError("Invalid or expired reset token", 400); + } + + const hashedPassword = await bcrypt.hash(newPassword, 12); + + await prisma.user.update({ + where: { id: user.id }, + data: { + password: hashedPassword, + passwordResetTokenHash: null, + passwordResetTokenExpiry: null, + }, + }); + + logger.info(`Password reset successful for user: ${user.username}`); + + return { + message: "Password reset successful", + }; await logAudit("PROFILE_UPDATED", userId, { username: updatedUser.username }); return updatedUser; + }; /** -<<<<<<< HEAD + * Request password reset email * @param {string} email - User email * @returns {Object} Generic response @@ -393,8 +491,16 @@ module.exports = { login, getProfile, updateProfile, + + forgotPassword, + resetPassword, + +}; + forgotPassword, resetPassword, + blacklistToken, isTokenBlacklisted, }; +