CampVerse CI
Deploy backend from GitHub Actions
d792ca0
Raw History Blame Contribute Delete
77.7 kB
/*
* ML Integration & Drive Structure (Planned/Current):
*
* 1. Event Recommendation:
* - Endpoint will call external ML API to fetch personalized event recommendations for users (for dashboard).
* - Fallback logic should be present if ML API is unavailable.
*
* 2. Certificate Generation:
* - Host selects/uploads certificate template for event.
* - Certificate generation endpoint will call ML API, restricted to users marked as 'attended'.
* - Generated certificates will be stored and linked to users.
* - (Planned) All certificates for an event will be stored in a Drive folder named after the event.
*
* 3. Event Images:
* - Logos and banners are uploaded to separate Drive folders (see driveService.js for details).
*
* These comments are for documentation and planning only; they do not affect code execution.
*/
/* eslint-disable quotes */
const User = require('../Models/User');
const Certificate = require('../Models/Certificate');
const Achievement = require('../Models/Achievement');
const EventParticipationLog = require('../Models/EventParticipationLog');
const Event = require('../Models/Event');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
// Use the simple, existing OTP generator without extra wrappers
const { otpgenrater } = require('../Services/otp');
let emailService;
try {
const emailModule = require('../Services/email');
if (typeof emailModule.createEmailService === 'function') {
emailService = emailModule.createEmailService();
} else {
emailService = { sendMail: async () => true };
}
} catch (e) {
emailService = { sendMail: async () => true };
}
const {
notifyHostRequest,
notifyHostStatusUpdate,
} = require('../Services/notification');
const { createClient } = require('redis');
const { OAuth2Client } = require('google-auth-library');
const winston = require('winston');
const { uploadProfilePhoto, deleteProfilePhoto } = require('../Services/driveService');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.Console()],
});
const crypto = require('crypto');
const { cacheService } = require('../Services/cacheService');
const { asyncHandler } = require('../Middleware/errorHandler');
// Import cookie helper for setting refresh token (separate module to avoid circular deps)
const { setRefreshTokenCookie } = require('../Utils/cookieUtils');
// const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; // not used directly
// Academic email domain check - supports:
// - .ac.in, .edu.in (Indian academic)
// - .edu.co.in (like tulas.edu.co.in)
// - .edu (US/international academic)
// - Specific allowed domains (like cuchd.in)
// NOTE: Does NOT allow arbitrary .in domains
const ALLOWED_SPECIFIC_DOMAINS = [
'cuchd.in', // Chandigarh University
];
const isAcademicEmail = (email) => {
const emailLower = email.toLowerCase();
const domain = emailLower.split('@')[1];
// Check if domain is in the explicitly allowed list
if (domain && ALLOWED_SPECIFIC_DOMAINS.some(allowed => domain === allowed || domain.endsWith('.' + allowed))) {
return true;
}
// Match allowed academic patterns only:
// 1. Ends with .ac.in or .edu.in
// 2. Ends with .edu (like .edu)
// 3. Ends with .edu.co.in
// NOTE: We specifically check for academic TLD patterns, not arbitrary domains
return (
/@[\w.-]+\.(ac|edu)\.in$/i.test(emailLower) ||
/@[\w.-]+\.edu$/i.test(emailLower) ||
/@[\w.-]+\.edu\.co\.in$/i.test(emailLower)
);
};
function extractDomain(email) {
return email.split('@')[1].toLowerCase();
}
async function findOrCreateInstitution(domain) {
const Institution = require('../Models/Institution');
// Don't create institutions automatically - let users request them
const institution = await Institution.findOne({ emailDomain: domain });
return institution; // Return null if no institution exists
}
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
});
redisClient.on('error', (err) => logger.error('Redis Client Error', err));
(async () => {
if (!redisClient.isOpen) await redisClient.connect();
logger.info('Redis connected');
})();
// Input validation helper functions
function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function validatePhone(phone) {
const phoneRegex = /^[0-9]{10}$/;
return phoneRegex.test(phone);
}
const { validatePassword } = require('../Utils/passwordUtils');
function validateName(name) {
return name && name.trim().length >= 2;
}
// ---------------- Google Sign-In ----------------
async function googleSignIn(req, res) {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'Google token missing.' });
}
// Handle mock tokens for testing (only in non-production environments)
if (token.startsWith('mock_google_token_') && process.env.NODE_ENV !== 'production') {
// Extract email and name from token if present
let mockEmail = 'test.user@cuj.ac.in';
let mockName = 'Test User';
const parts = token.split('__');
if (parts.length === 2) {
mockEmail = parts[1];
mockName = mockEmail
.split('@')[0]
.replace(/\./g, ' ')
.replace(/\d+/g, '')
.replace(/(^|\s)\S/g, (l) => l.toUpperCase());
}
if (!isAcademicEmail(mockEmail)) {
return res
.status(400)
.json({
error:
"Only academic emails (.ac.in, .edu.in, .edu) or approved domains (e.g., cuchd.in) are allowed.",
forceLogout: true,
});
}
let user = await User.findOne({ email: mockEmail });
if (!user) {
// Create Google-first account with cryptographically secure password
const crypto = require('crypto');
const randomPassword = crypto.randomBytes(32).toString('hex');
const passwordHash = await bcrypt.hash(randomPassword, 10);
user = new User({
name: mockName,
email: mockEmail,
phone: "",
profilePhoto: "",
passwordHash,
passwordSetup: false,
roles: ["student"],
isVerified: true,
canHost: false,
googleLinked: true,
authMethods: ["google"],
primaryAuthMethod: "google",
createdAt: new Date(),
});
await user.save();
} else {
if (!user.googleLinked) user.googleLinked = true;
user.authMethods = Array.isArray(user.authMethods)
? user.authMethods
: [];
if (!user.authMethods.includes("google"))
user.authMethods.push("google");
}
user.lastLogin = new Date();
await user.save();
// Use new token service for access + refresh tokens
try {
const { generateTokenPair } = require('../Services/tokenService');
const tokens = await generateTokenPair(user, req, 'google');
logger.info("Mock Google login successful for user:", { email: mockEmail, timestamp: new Date().toISOString() });
// Set refresh token as HttpOnly cookie
logger.info('Setting mock refresh token cookie for Google user:', { id: user._id, email: user.email });
setRefreshTokenCookie(res, tokens.refreshToken);
return res.json({
message: "Google login successful (mock)",
token: tokens.accessToken,
expiresIn: tokens.expiresIn,
user: sanitizeUser(user),
});
} catch (tokenError) {
// Fallback to old token generation
const jwtToken = jwt.sign(
{ id: user._id, roles: user.roles, name: user.name },
process.env.JWT_SECRET,
{
expiresIn: "1h",
issuer: "campverse",
audience: "campverse-users",
},
);
logger.info("Mock Google login successful for user:", { email: mockEmail, timestamp: new Date().toISOString() });
return res.json({
message: "Google login successful (mock)",
token: jwtToken,
user: sanitizeUser(user),
});
}
}
// Real Google OAuth implementation
try {
let email, name, picture;
const clientId = process.env.GOOGLE_CLIENT_ID;
const oauthClient = new OAuth2Client(clientId);
// First try treating the token as an ID token (most frontends provide this)
try {
const ticket = await oauthClient.verifyIdToken({
idToken: token,
audience: clientId,
});
const payload = ticket.getPayload();
email = payload.email;
name = payload.name;
picture = payload.picture;
} catch (e) {
// Fallback: treat token as an access token and call userinfo
const userInfoResponse = await fetch(
`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${token}`,
);
if (!userInfoResponse.ok) {
throw new Error("Failed to fetch user info from Google");
}
const userInfo = await userInfoResponse.json();
email = userInfo.email;
name = userInfo.name;
picture = userInfo.picture;
}
if (!email) {
return res.status(400).json({ error: "Email not provided by Google." });
}
if (!isAcademicEmail(email)) {
return res
.status(400)
.json({
error:
"Only academic emails (.ac.in, .edu.in, .edu, .edu.co.in, etc.) or approved domains (e.g., cuchd.in) are allowed.",
forceLogout: true,
});
}
let user = await User.findOne({ email });
if (!user) {
// Create Google-first account with cryptographically secure password
const crypto = require('crypto');
const randomPassword = crypto.randomBytes(32).toString('hex');
const passwordHash = await bcrypt.hash(randomPassword, 10);
user = new User({
name: name || email.split("@")[0],
email,
phone: "",
profilePhoto: picture || "",
passwordHash,
// Explicitly mark that password isn't user-set yet
passwordSetup: false,
roles: ["student"],
isVerified: true,
canHost: false,
googleLinked: true,
authMethods: ["google"],
primaryAuthMethod: "google",
createdAt: new Date(),
});
await user.save();
} else {
// User exists - update profile photo if provided and user doesn't have one
if (picture && !user.profilePhoto) {
user.profilePhoto = picture;
}
// Update name if Google provides a better one (longer/more complete)
if (name && name.length > user.name.length) {
user.name = name;
}
// Mark as Google linked if not already
if (!user.googleLinked) user.googleLinked = true;
// Ensure authMethods reflect Google capability
user.authMethods = Array.isArray(user.authMethods)
? user.authMethods
: [];
if (!user.authMethods.includes("google"))
user.authMethods.push("google");
}
user.lastLogin = new Date();
await user.save();
// Use new token service for access + refresh tokens
try {
const { generateTokenPair } = require('../Services/tokenService');
const tokens = await generateTokenPair(user, req, 'google');
logger.info("Google login successful for user:", { email, timestamp: new Date().toISOString() });
// Set refresh token as HttpOnly cookie
logger.info('Setting refresh token cookie for Google user:', { id: user._id, email: user.email });
setRefreshTokenCookie(res, tokens.refreshToken);
return res.json({
message: "Google login successful",
token: tokens.accessToken,
expiresIn: tokens.expiresIn,
user: sanitizeUser(user),
});
} catch (tokenError) {
// Fallback to old token generation
const jwtToken = jwt.sign(
{ id: user._id, roles: user.roles, name: user.name },
process.env.JWT_SECRET,
{
expiresIn: "1h",
issuer: "campverse",
audience: "campverse-users",
},
);
logger.info("Google login successful for user:", { email, timestamp: new Date().toISOString() });
return res.json({
message: "Google login successful",
token: jwtToken,
user: sanitizeUser(user),
});
}
} catch (googleError) {
logger.error("Google token verification failed:", { error: googleError.message, timestamp: new Date().toISOString() });
logger.error("Google token verification failed:", googleError);
// Log failed attempt
try {
const { logFailedLogin } = require('../Services/tokenService');
await logFailedLogin(null, 'oauth_error', req, 'google');
} catch (e) { /* ignore */ }
return res.status(401).json({ error: "Invalid Google token." });
}
} catch (err) {
logger.error("Google Login Error:", err);
return res.status(500).json({ error: "Google login failed." });
}
}
// ---------------- Setup Password for Google Users ----------------
async function setupPasswordForGoogleUser(req, res) {
try {
const { newPassword } = req.body;
const userId = req.user.id;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found." });
}
// If password is already set up, disallow here
if (user.passwordSetup) {
return res
.status(400)
.json({
error: "Password is already set. Use change password instead.",
});
}
// Validate new password
if (!validatePassword(newPassword)) {
return res.status(400).json({
error: "Password must be at least 8 characters long and include uppercase, lowercase, numbers, and special characters.",
});
}
// Hash the new password
const passwordHash = await bcrypt.hash(newPassword, 10);
// Update user with new password and mark as verified
user.passwordHash = passwordHash;
user.passwordSetup = true;
user.isVerified = true;
user.googleLinked = true; // ensure dual login capability
user.authMethods = Array.isArray(user.authMethods) ? user.authMethods : [];
if (!user.authMethods.includes("password"))
user.authMethods.push("password");
if (!user.primaryAuthMethod) user.primaryAuthMethod = "password";
await user.save();
return res.json({
message:
"Password set up successfully. You can now use email/password login.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("Setup Password Error:", err);
return res.status(500).json({ error: "Failed to set up password." });
}
}
// ---------------- Change Password (for all users) ----------------
async function changePassword(req, res) {
try {
const { currentPassword, newPassword } = req.body;
const userId = req.user.id;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found." });
}
// Verify current password
const validPass = await bcrypt.compare(currentPassword, user.passwordHash);
if (!validPass) {
return res.status(400).json({ error: "Current password is incorrect." });
}
// Validate new password
if (!validatePassword(newPassword)) {
return res.status(400).json({
error: "Password must be at least 8 characters long and include uppercase, lowercase, numbers, and special characters.",
});
}
// Hash the new password
const passwordHash = await bcrypt.hash(newPassword, 10);
user.passwordHash = passwordHash;
await user.save();
return res.json({
message: "Password changed successfully.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("Change Password Error:", err);
return res.status(500).json({ error: "Failed to change password." });
}
}
// ---------------- Send OTP for Google User Verification ----------------
async function sendOtpForGoogleUser(req, res) {
try {
const userId = req.user.id;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found." });
}
// Check if user is already verified
if (user.isVerified) {
return res.status(400).json({
error: "Account is already verified.",
});
}
// Generate and send OTP
const otp = otpgenrater();
try {
await emailService.sendMail({
from: process.env.EMAIL_USER,
to: user.email,
subject: "Verify Your CampVerse Account",
text: `Your verification code is: ${otp}. Please enter it within 5 minutes.`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">CampVerse Account Verification</h2>
<p>Hello ${user.name},</p>
<p>Your verification code is: <strong style="font-size: 24px; color: #007bff;">${otp}</strong></p>
<p>Please enter this code within 5 minutes to verify your account.</p>
<p>If you didn't request this code, please ignore this email.</p>
<hr>
<p style="color: #666; font-size: 12px;">This is an automated message from CampVerse.</p>
</div>
`,
});
// Store OTP in Redis with user ID
await redisClient.setEx(
`verify_google_user:${userId}`,
300,
JSON.stringify({ otp, email: user.email }),
);
return res.json({
message: "Verification code sent to your email.",
note: "Enter the code to verify your account.",
});
} catch (emailError) {
logger.error("Email sending failed:", emailError.message);
return res.status(500).json({
error: "Failed to send verification email. Please try again.",
});
}
} catch (err) {
logger.error("Send OTP for Google User Error:", err);
return res.status(500).json({ error: "Failed to send verification code." });
}
}
// ---------------- Verify OTP for Google User ----------------
async function verifyOtpForGoogleUser(req, res) {
try {
const { otp } = req.body;
const userId = req.user.id;
if (!otp) {
return res.status(400).json({ error: "OTP is required." });
}
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found." });
}
// Get stored OTP from Redis
const storedData = await redisClient.get(`verify_google_user:${userId}`);
if (!storedData) {
return res
.status(400)
.json({ error: "OTP expired or not found. Please request a new one." });
}
const { otp: storedOtp } = JSON.parse(storedData);
if (storedOtp !== otp) {
return res.status(400).json({ error: "Invalid OTP." });
}
// Verify the user account
user.isVerified = true;
await user.save();
// Clear the OTP from Redis
await redisClient.del(`verify_google_user:${userId}`);
return res.json({
message: "Account verified successfully!",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("Verify OTP for Google User Error:", err);
return res.status(500).json({ error: "Failed to verify account." });
}
}
// ---------------- Get User Authentication Status ----------------
async function getAuthStatus(req, res) {
try {
const userId = req.user.id;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found." });
}
const hasPassword = !!user.passwordSetup;
return res.json({
hasPassword,
isVerified: user.isVerified,
googleLinked: user.googleLinked,
canUseEmailLogin: hasPassword,
canUseGoogleLogin: true, // All users can use Google login
needsVerification: !user.isVerified,
needsPasswordSetup: !hasPassword,
});
} catch (err) {
logger.error("Get Auth Status Error:", err);
return res
.status(500)
.json({ error: "Failed to get authentication status." });
}
}
// ---------------- Link Google Account ----------------
async function linkGoogleAccount(req, res) {
try {
const { email, password, googleToken } = req.body;
if (!email || !password || !googleToken) {
return res.status(400).json({
error: "Email, password, and Google token are required.",
});
}
// First verify the user's email/password
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ error: "User not found." });
}
const validPass = await bcrypt.compare(password, user.passwordHash);
if (!validPass) {
return res.status(400).json({ error: "Incorrect password." });
}
// Verify Google token and extract Google account info
try {
const clientId = process.env.GOOGLE_CLIENT_ID;
const oauthClient = new OAuth2Client(clientId);
let googleEmail, googleName, googlePicture;
try {
const ticket = await oauthClient.verifyIdToken({
idToken: googleToken,
audience: clientId,
});
const payload = ticket.getPayload();
googleEmail = payload.email;
googleName = payload.name;
googlePicture = payload.picture;
} catch (e) {
// Fallback: treat token as access token
const userInfoResponse = await fetch(
`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${googleToken}`,
);
if (!userInfoResponse.ok) {
throw new Error("Failed to fetch user info from Google");
}
const userInfo = await userInfoResponse.json();
googleEmail = userInfo.email;
googleName = userInfo.name;
googlePicture = userInfo.picture;
}
// Verify Google email matches user email
if (googleEmail.toLowerCase() !== email.toLowerCase()) {
return res.status(400).json({
error:
"Google account email must match your registered email address.",
});
}
// Update user with Google profile info
if (googlePicture && !user.profilePhoto) {
user.profilePhoto = googlePicture;
}
if (googleName && googleName.length > user.name.length) {
user.name = googleName;
}
// Mark that this user can now use Google login
user.googleLinked = true;
user.authMethods = Array.isArray(user.authMethods)
? user.authMethods
: [];
if (!user.authMethods.includes("google")) user.authMethods.push("google");
if (!user.primaryAuthMethod) user.primaryAuthMethod = "password";
await user.save();
return res.json({
message:
"Google account linked successfully. You can now use Google login.",
user: sanitizeUser(user),
});
} catch (googleError) {
logger.error("Google token verification failed:", googleError);
return res.status(401).json({ error: "Invalid Google token." });
}
} catch (err) {
logger.error("Link Google Account Error:", err);
return res.status(500).json({ error: "Failed to link Google account." });
}
}
// ---------------- Register ----------------
async function register(req, res) {
try {
const { name, email, phone, password } = req.body;
// Comprehensive validation
if (!name || !email || !phone || !password) {
return res.status(400).json({
error: "All fields (name, email, phone, password) are required.",
missing: {
name: !name,
email: !email,
phone: !phone,
password: !password,
},
});
}
if (!validateName(name)) {
return res
.status(400)
.json({ error: "Name must be at least 2 characters long." });
}
if (!validateEmail(email)) {
return res
.status(400)
.json({ error: "Please provide a valid email address." });
}
if (!isAcademicEmail(email)) {
return res
.status(400)
.json({
error: "Only academic emails (.ac.in, .edu.in, .edu, .edu.co.in, etc.) or approved domains (e.g., cuchd.in) are allowed.",
});
}
if (!validatePhone(phone)) {
return res
.status(400)
.json({ error: "Please provide a valid 10-digit phone number." });
}
if (!validatePassword(password)) {
return res
.status(400)
.json({ error: "Password must be at least 8 characters long and include uppercase, lowercase, numbers, and special characters." });
}
const existingUser = await User.findOne({ email });
if (existingUser) {
return res
.status(400)
.json({ error: "User with this email already exists." });
}
const otp = otpgenrater();
let emailSent = false;
try {
await emailService.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: "Your Verification Code",
text: `Your verification code is: ${otp}. Please enter it within 5 minutes.`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">CampVerse Verification Code</h2>
<p>Hello ${name},</p>
<p>Your verification code is: <strong style="font-size: 24px; color: #007bff;">${otp}</strong></p>
<p>Please enter this code within 5 minutes to complete your registration.</p>
<p>If you didn't request this code, please ignore this email.</p>
<hr>
<p style="color: #666; font-size: 12px;">This is an automated message from CampVerse.</p>
</div>
`,
});
logger.info(`Email sent successfully to ${email}`);
emailSent = true;
} catch (emailError) {
logger.error("Email sending failed:", emailError.message);
logger.error("Email error details:", emailError);
if (process.env.NODE_ENV !== 'production') {
logger.warn('Development mode: Proceeding without email verification');
emailSent = true;
} else {
return res.status(500).json({ error: "Failed to send verification email. Please check your email configuration." });
}
}
if (emailSent) {
const domain = extractDomain(email);
const institution = await findOrCreateInstitution(domain);
const tempData = {
name,
phone,
password,
otp,
institutionId: institution ? institution._id : null,
institutionIsVerified: institution ? institution.isVerified : "none",
};
await redisClient.setEx(email, 600, JSON.stringify(tempData));
return res.status(200).json({ message: "OTP sent to email." });
}
} catch (err) {
logger.error("Register error:", err);
return res
.status(500)
.json({ error: "Server error during registration. Please try again." });
}
}
// Verify OTP
async function verifyOtp(req, res) {
try {
const { email, otp } = req.body;
if (!email || !otp)
return res.status(400).json({ error: "Email and OTP required." });
const tempStr = await redisClient.get(email);
if (!tempStr)
return res.status(400).json({ error: "OTP expired or invalid." });
const tempData = JSON.parse(tempStr);
if (tempData.locked)
return res
.status(429)
.json({ error: "Too many failed attempts. Please request a new OTP." });
tempData.retryCount = tempData.retryCount || 0;
if (tempData.otp !== otp) {
tempData.retryCount++;
if (tempData.retryCount >= 5) {
tempData.locked = true;
await redisClient.setEx(email, 600, JSON.stringify(tempData));
return res
.status(429)
.json({
error: "Too many failed attempts. Please request a new OTP.",
});
}
await redisClient.setEx(email, 600, JSON.stringify(tempData));
return res.status(400).json({ error: "Invalid OTP." });
}
let user = await User.findOne({ email });
if (user) {
// Existing user: if password not set yet (Google-first), set it now from tempData
if (!user.passwordSetup && tempData && tempData.password) {
user.passwordHash = await bcrypt.hash(tempData.password, 10);
user.passwordSetup = true;
user.authMethods = Array.isArray(user.authMethods)
? user.authMethods
: [];
if (!user.authMethods.includes("password"))
user.authMethods.push("password");
if (!user.primaryAuthMethod) user.primaryAuthMethod = "password";
await user.save();
}
await redisClient.del(email);
// Use new token service for access + refresh tokens
try {
const { generateTokenPair } = require('../Services/tokenService');
const tokens = await generateTokenPair(user, req, 'email');
// Set refresh token as HttpOnly cookie
setRefreshTokenCookie(res, tokens.refreshToken);
return res.json({
message: "OTP verified, logged in.",
token: tokens.accessToken,
expiresIn: tokens.expiresIn,
user: sanitizeUser(user),
});
} catch (tokenError) {
// Fallback to old token generation
const token = jwt.sign(
{ id: user._id, roles: user.roles, name: user.name },
process.env.JWT_SECRET,
{
expiresIn: "1h",
issuer: "campverse",
audience: "campverse-users",
},
);
return res.json({
message: "OTP verified, logged in.",
token,
user: sanitizeUser(user),
});
}
}
// New user registration - no automatic institution creation
const passwordHash = await bcrypt.hash(tempData.password, 10);
user = new User({
name: tempData.name,
email,
phone: tempData.phone,
passwordHash,
passwordSetup: true,
roles: ["student"],
isVerified: false,
canHost: false,
createdAt: new Date(),
authMethods: ["password"],
primaryAuthMethod: "password",
// Don't set institutionId automatically - user must request institution
institutionVerificationStatus: "pending", // No institution requested yet
});
await user.save();
await redisClient.del(email);
// Use new token service for access + refresh tokens
try {
const { generateTokenPair } = require('../Services/tokenService');
const tokens = await generateTokenPair(user, req, 'email');
// Set refresh token as HttpOnly cookie
setRefreshTokenCookie(res, tokens.refreshToken);
return res.status(201).json({
message: "Registration successful, logged in.",
token: tokens.accessToken,
expiresIn: tokens.expiresIn,
user: sanitizeUser(user),
});
} catch (tokenError) {
// Fallback to old token generation
const token = jwt.sign(
{ id: user._id, roles: user.roles, name: user.name },
process.env.JWT_SECRET,
{
expiresIn: "1h",
issuer: "campverse",
audience: "campverse-users",
},
);
return res.status(201).json({
message: "Registration successful, logged in.",
token,
user: sanitizeUser(user),
});
}
} catch (err) {
logger.error("Verify OTP error:", err);
return res
.status(500)
.json({ error: "Server error during OTP verification." });
}
}
// Login with email & password
async function login(req, res) {
try {
const { email, password } = req.body;
if (!email || !password)
return res.status(400).json({ error: "Email and password required." });
const user = await User.findOne({ email });
if (!user) {
// Log failed attempt (unknown user)
try {
const { logFailedLogin } = require('../Services/tokenService');
await logFailedLogin(null, 'invalid_email', req, 'email');
} catch (e) { /* ignore logging errors */ }
return res.status(400).json({ error: "User not found." });
}
const validPass = await bcrypt.compare(password, user.passwordHash);
if (!validPass) {
// Log failed attempt
try {
const { logFailedLogin } = require('../Services/tokenService');
await logFailedLogin(user._id, 'invalid_password', req, 'email');
} catch (e) { /* ignore logging errors */ }
return res.status(400).json({ error: "Incorrect password." });
}
user.lastLogin = new Date();
await user.save();
// Use new token service for access + refresh tokens
try {
const { generateTokenPair } = require('../Services/tokenService');
const tokens = await generateTokenPair(user, req, 'email');
// Set refresh token as HttpOnly cookie
logger.info('Setting refresh token cookie for user:', { id: user._id, email: user.email });
setRefreshTokenCookie(res, tokens.refreshToken);
return res.json({
token: tokens.accessToken,
expiresIn: tokens.expiresIn,
user: sanitizeUser(user),
});
} catch (tokenError) {
// Fallback to old token generation if new service fails
logger.error("Token service error, using fallback:", tokenError);
const token = jwt.sign(
{ id: user._id, roles: user.roles, name: user.name },
process.env.JWT_SECRET,
{
expiresIn: "1h",
issuer: "campverse",
audience: "campverse-users",
},
);
return res.json({
token,
user: sanitizeUser(user),
});
}
} catch (err) {
logger.error("Login error:", err);
return res.status(500).json({ error: "Server error during login." });
}
}
// Update user preferences (POST /updatePreferences)
async function updatePreferences(req, res) {
try {
const userId = req.user.id;
const updates = req.body;
// Allow all profile fields to be updated
const allowedFields = [
"name",
"phone",
"gender",
"dateOfBirth",
"profilePhoto",
"collegeIdNumber",
"interests",
"skills",
"learningGoals",
"badges",
"location",
"bio",
];
const filteredUpdates = {};
for (const key of allowedFields) {
if (key in updates) filteredUpdates[key] = updates[key];
}
const updatedUser = await User.findByIdAndUpdate(userId, filteredUpdates, {
new: true,
runValidators: true,
}).select("-passwordHash");
if (!updatedUser) return res.status(404).json({ error: "User not found." });
return res.json({ message: "Preferences updated.", user: updatedUser });
} catch (err) {
logger.error("Update preferences error:", err);
return res
.status(500)
.json({ error: "Server error updating preferences." });
}
}
// Get logged in user profile (GET /me)
async function getMe(req, res) {
try {
const user = await User.findById(req.user.id)
.populate("institutionId", "name isVerified")
.select("-passwordHash");
if (!user) return res.status(404).json({ error: "User not found." });
// Ensure institutionId is null if no institution exists
if (user.institutionId && !user.institutionId._id) {
user.institutionId = null;
user.institutionVerificationStatus = "none";
}
return res.json(user);
} catch (err) {
logger.error("GetMe error:", err);
return res.status(500).json({ error: "Server error fetching profile." });
}
}
// PATCH /me — update own profile
async function updateMe(req, res) {
try {
const userId = req.user.id;
const updates = req.body;
// Allow all profile fields to be updated
const allowedFields = [
"name",
"phone",
"gender",
"dateOfBirth",
"profilePhoto",
"collegeIdNumber",
"interests",
"skills",
"learningGoals",
"badges",
"location",
"onboardingCompleted",
"bio",
];
const filteredUpdates = {};
for (const key of allowedFields) {
if (key in updates) filteredUpdates[key] = updates[key];
}
if (Object.keys(filteredUpdates).length === 0) {
return res.status(400).json({ error: "No valid fields to update." });
}
const updatedUser = await User.findByIdAndUpdate(userId, filteredUpdates, {
new: true,
runValidators: true,
}).select("-passwordHash");
if (!updatedUser) return res.status(404).json({ error: "User not found." });
return res.json({ message: "Profile updated.", user: updatedUser });
} catch (err) {
logger.error("UpdateMe error:", err);
return res.status(500).json({ error: "Server error updating profile." });
}
}
// Get user by id (GET /:id)
async function getUserById(req, res) {
try {
const user = await User.findById(req.params.id).select("-passwordHash");
if (!user) return res.status(404).json({ error: "User not found." });
return res.json(user);
} catch (err) {
logger.error("GetUserById error:", err);
return res.status(500).json({ error: "Server error fetching user." });
}
}
// Update user by id (PATCH /:id)
async function updateUserById(req, res) {
try {
const updates = req.body;
// For security, don't allow password or roles update here unless you want to
if ("passwordHash" in updates) delete updates.passwordHash;
const updatedUser = await User.findByIdAndUpdate(req.params.id, updates, {
new: true,
runValidators: true,
}).select("-passwordHash");
if (!updatedUser) return res.status(404).json({ error: "User not found." });
return res.json({ message: "User updated.", user: updatedUser });
} catch (err) {
logger.error("UpdateUserById error:", err);
return res.status(500).json({ error: "Server error updating user." });
}
}
/**
* GET /me/dashboard — User dashboard stats
*/
async function getDashboard(req, res) {
try {
const user = await User.findById(req.user.id)
.populate("eventHistory.hosted")
.populate("eventHistory.attended")
.populate("eventHistory.saved")
.populate("eventHistory.waitlisted")
.select("-passwordHash");
if (!user) return res.status(404).json({ error: "User not found." });
// Get certificates count
const certificatesCount = await Certificate.countDocuments({
userId: user._id,
});
// Get achievements count
const achievementsCount = await Achievement.countDocuments({
userId: user._id,
});
// Get participation logs for more detailed stats
const participationLogs = await EventParticipationLog.find({
userId: user._id,
}).populate({
path: 'eventId',
populate: {
path: 'hostUserId',
select: 'name email profilePicture'
}
});
const registeredEvents = participationLogs.filter(
(log) => log.status === "registered",
).length;
// Get registered events with full details for dashboard
const registeredEventsWithDetails = participationLogs
.filter(log => log.status === "registered" && log.eventId)
.map(log => ({
...log.eventId.toObject(),
userRegistration: {
status: log.status,
registeredAt: log.registeredAt,
qrToken: log.qrToken
}
}));
// Upcoming events count for the user (registered and in the future)
const now = new Date();
const registeredEventIds = participationLogs
.filter((log) => log.status === "registered")
.map((log) => log.eventId);
let upcomingEventsCount = 0;
if (registeredEventIds.length > 0) {
upcomingEventsCount = await Event.countDocuments({
_id: { $in: registeredEventIds },
date: { $gt: now },
});
}
// Profile completion calculation
const requiredFields = [
"name",
"email",
"phone",
"gender",
"dateOfBirth",
"profilePhoto",
"collegeIdNumber",
];
let filled = 0;
requiredFields.forEach((f) => {
if (user[f]) filled++;
});
const profileCompletion = Math.round(
(filled / requiredFields.length) * 100,
);
// Enhanced Stats
const stats = {
totalAttended: user.eventHistory?.attended?.length || 0,
totalHosted: user.eventHistory?.hosted?.length || 0,
totalSaved: user.eventHistory?.saved?.length || 0,
totalWaitlisted: participationLogs.filter(
(log) => log.status === "waitlisted",
).length,
totalRegistered: registeredEvents,
totalParticipationLogs: participationLogs.length,
certificates: certificatesCount,
achievements: achievementsCount,
upcomingEvents: upcomingEventsCount,
myColleges: user.institutionId ? 1 : 0,
referralStats: user.referralStats || {
sharedLinks: 0,
successfulSignups: 0,
},
profileCompletion,
isHost: user.roles.includes("host"),
isVerifier: user.roles.includes("verifier"),
hostEligibilityStatus: user.hostEligibilityStatus,
verifierEligibilityStatus: user.verifierEligibilityStatus,
institutionVerificationStatus: user.institutionVerificationStatus,
lastLogin: user.lastLogin,
accountCreated: user.createdAt,
accountAge: Math.floor(
(Date.now() - user.createdAt) / (1000 * 60 * 60 * 24),
), // days since account creation
};
return res.json({
user,
stats,
events: registeredEventsWithDetails // Include registered events in dashboard response
});
} catch (err) {
logger.error("GetDashboard error:", err);
return res.status(500).json({ error: "Server error fetching dashboard." });
}
}
// Get certificates of user (GET /:id/certificates)
async function getUserCertificates(req, res) {
try {
const certificates = await Certificate.find({ userId: req.params.id });
return res.json(certificates);
} catch (err) {
logger.error("GetUserCertificates error:", err);
return res
.status(500)
.json({ error: "Server error fetching certificates." });
}
}
// Get achievements of user (GET /:id/achievements)
async function getUserAchievements(req, res) {
try {
const achievements = await Achievement.find({ userId: req.params.id });
return res.json(achievements);
} catch (err) {
logger.error("GetUserAchievements error:", err);
return res
.status(500)
.json({ error: "Server error fetching achievements." });
}
}
// Get events related to user (hosted, attended, saved, waitlisted) (GET /:id/events)
async function getUserEvents(req, res) {
try {
const user = await User.findById(req.params.id)
.populate("eventHistory.hosted")
.populate("eventHistory.attended")
.populate("eventHistory.saved")
.populate("eventHistory.waitlisted");
if (!user) return res.status(404).json({ error: "User not found." });
return res.json({
hosted: user.eventHistory?.hosted || [],
attended: user.eventHistory?.attended || [],
saved: user.eventHistory?.saved || [],
waitlisted: user.eventHistory?.waitlisted || [],
});
} catch (err) {
logger.error("GetUserEvents error:", err);
return res
.status(500)
.json({ error: "Server error fetching user events." });
}
}
// Grant host access (platformAdmin only)
async function grantHostAccess(req, res) {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found." });
user.canHost = true;
if (!user.roles.includes("host")) user.roles.push("host");
user.hostEligibilityStatus = {
status: "approved",
approvedBy: req.user.id,
approvedAt: new Date(),
remarks: req.body.remarks || "Approved by platform admin",
};
await user.save();
// Invalidate cache so admin dashboard shows updated data
await cacheService.invalidateUser(user._id);
await cacheService.invalidateAdminCache();
await cacheService.invalidateVerifierCache();
// Notify user about host access granted
await notifyHostStatusUpdate(
user._id,
user.name,
user.email,
"approved",
req.body.remarks,
);
return res.json({
message: "Host access granted.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("GrantHostAccess error:", err);
return res
.status(500)
.json({ error: "Server error granting host access." });
}
}
// Grant verifier access (POST /:id/grant-verifier) — only platformAdmin middleware should protect
async function grantVerifierAccess(req, res) {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found." });
if (!user.roles.includes("verifier")) {
user.roles.push("verifier");
}
user.verifierEligibilityStatus = {
approvedBy: req.user.id,
approvedAt: new Date(),
remarks: req.body.remarks || "Approved by platform admin",
};
await user.save();
// Invalidate cache so admin dashboard shows updated data
await cacheService.invalidateUser(user._id);
await cacheService.invalidateAdminCache();
await cacheService.invalidateVerifierCache();
return res.json({
message: "Verifier access granted.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("GrantVerifierAccess error:", err);
return res
.status(500)
.json({ error: "Server error granting verifier access." });
}
}
// Request host access (user self-request)
async function requestHostAccess(req, res) {
try {
const userId = req.user.id;
const remarks = req.body && req.body.remarks ? req.body.remarks : "";
const user = await User.findById(userId);
if (!user) return res.status(404).json({ error: "User not found." });
if (
user.hostEligibilityStatus &&
user.hostEligibilityStatus.status === "pending"
) {
return res.status(400).json({ error: "Host request already pending." });
}
if (user.roles.includes("host")) {
return res.status(400).json({ error: "User is already a host." });
}
// Handle file uploads with validation and storage based on STORAGE_PROVIDER
const storageProvider = process.env.STORAGE_PROVIDER || 'firebase';
const { firebaseStorageService } = require('../Services/firebaseStorageService');
const { supabaseStorageService } = require('../Services/supabaseStorageService');
const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf'];
const maxSize = 2 * 1024 * 1024; // 2MB
let idCardPhotoUrl = "";
let eventPermissionUrl = "";
if (req.files && req.files.idCardPhoto && req.files.idCardPhoto[0]) {
const file = req.files.idCardPhoto[0];
if (!allowedTypes.includes(file.mimetype)) {
return res.status(400).json({ error: "Invalid ID card photo type. Only JPEG, PNG, and PDF allowed." });
}
if (file.size > maxSize) {
return res.status(400).json({ error: "ID card photo too large (max 2MB)." });
}
// Upload based on STORAGE_PROVIDER setting
try {
if (storageProvider === 'firebase') {
// Upload to Firebase only
idCardPhotoUrl = await firebaseStorageService.uploadUserDocument(
file.buffer,
file.originalname,
'id-cards',
userId,
file.mimetype
);
logger.info(`ID card uploaded to Firebase for user ${userId}`);
} else if (storageProvider === 'supabase') {
// Upload to Supabase only
idCardPhotoUrl = await supabaseStorageService.uploadUserDocument(
file.buffer,
file.originalname,
'id-cards',
userId,
file.mimetype
);
logger.info(`ID card uploaded to Supabase for user ${userId}`);
} else {
// Default to Firebase if provider is unknown
idCardPhotoUrl = await firebaseStorageService.uploadUserDocument(
file.buffer,
file.originalname,
'id-cards',
userId,
file.mimetype
);
logger.info(`ID card uploaded to Firebase (default) for user ${userId}`);
}
} catch (uploadError) {
logger.error('Failed to upload ID card:', uploadError);
return res.status(500).json({ error: "Failed to upload ID card. Please try again." });
}
} else {
return res.status(400).json({ error: "ID card photo is required." });
}
if (req.files && req.files.eventPermission && req.files.eventPermission[0]) {
const file = req.files.eventPermission[0];
if (!allowedTypes.includes(file.mimetype)) {
return res.status(400).json({ error: "Invalid event permission file type. Only JPEG, PNG, and PDF allowed." });
}
if (file.size > maxSize) {
return res.status(400).json({ error: "Event permission file too large (max 2MB)." });
}
// Upload based on STORAGE_PROVIDER setting
try {
if (storageProvider === 'firebase') {
// Upload to Firebase only
eventPermissionUrl = await firebaseStorageService.uploadUserDocument(
file.buffer,
file.originalname,
'permissions',
userId,
file.mimetype
);
logger.info(`Event permission uploaded to Firebase for user ${userId}`);
} else if (storageProvider === 'supabase') {
// Upload to Supabase only
eventPermissionUrl = await supabaseStorageService.uploadUserDocument(
file.buffer,
file.originalname,
'permissions',
userId,
file.mimetype
);
logger.info(`Event permission uploaded to Supabase for user ${userId}`);
} else {
// Default to Firebase if provider is unknown
eventPermissionUrl = await firebaseStorageService.uploadUserDocument(
file.buffer,
file.originalname,
'permissions',
userId,
file.mimetype
);
logger.info(`Event permission uploaded to Firebase (default) for user ${userId}`);
}
} catch (uploadError) {
logger.error('Failed to upload event permission:', uploadError);
return res.status(500).json({ error: "Failed to upload event permission. Please try again." });
}
}
user.hostEligibilityStatus = {
status: "pending",
requestedAt: new Date(),
remarks,
};
user.hostRequestIdCardPhoto = idCardPhotoUrl;
user.hostRequestEventPermission = eventPermissionUrl;
await user.save();
// Notify platform admins about new host request
await notifyHostRequest(userId, user.name, user.email);
return res.json({
message: "Host request submitted.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("RequestHostAccess error:", err);
return res
.status(500)
.json({ error: err.message || "Server error requesting host access." });
}
}
// Approve host request (verifier only)
async function approveHostRequest(req, res) {
try {
if (!req.user.roles.includes("verifier")) {
return res
.status(403)
.json({ error: "Only verifiers can approve host requests." });
}
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found." });
if (
!user.hostEligibilityStatus ||
user.hostEligibilityStatus.status !== "pending"
) {
return res
.status(400)
.json({ error: "No pending host request for this user." });
}
user.hostEligibilityStatus.status = "approved";
user.hostEligibilityStatus.approvedBy = req.user.id;
user.hostEligibilityStatus.approvedAt = new Date();
user.hostEligibilityStatus.remarks =
req.body.remarks || "Approved by verifier";
user.canHost = true;
if (!user.roles.includes("host")) user.roles.push("host");
await user.save();
// Invalidate cache so verifier dashboard shows updated data
await cacheService.invalidateUser(user._id);
await cacheService.invalidateVerifierCache();
await cacheService.invalidateAdminCache();
// Notify user about host request approval
await notifyHostStatusUpdate(
user._id,
user.name,
user.email,
"approved",
req.body.remarks,
);
return res.json({
message: "Host request approved.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("ApproveHostRequest error:", err);
return res
.status(500)
.json({ error: "Server error approving host request." });
}
}
// Reject host request (verifier only)
async function rejectHostRequest(req, res) {
try {
if (!req.user.roles.includes("verifier")) {
return res
.status(403)
.json({ error: "Only verifiers can reject host requests." });
}
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found." });
if (
!user.hostEligibilityStatus ||
user.hostEligibilityStatus.status !== "pending"
) {
return res
.status(400)
.json({ error: "No pending host request for this user." });
}
user.hostEligibilityStatus.status = "rejected";
user.hostEligibilityStatus.approvedBy = req.user.id;
user.hostEligibilityStatus.approvedAt = new Date();
user.hostEligibilityStatus.remarks =
req.body.remarks || "Rejected by verifier";
user.canHost = false;
user.roles = user.roles.filter((r) => r !== "host");
await user.save();
// Invalidate cache so verifier dashboard shows updated data
await cacheService.invalidateUser(user._id);
await cacheService.invalidateVerifierCache();
await cacheService.invalidateAdminCache();
// Notify user about host request rejection
await notifyHostStatusUpdate(
user._id,
user.name,
user.email,
"rejected",
req.body.remarks,
);
return res.json({
message: "Host request rejected.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("RejectHostRequest error:", err);
return res
.status(500)
.json({ error: "Server error rejecting host request." });
}
}
// List all pending host requests (verifier only)
async function listPendingHostRequests(req, res) {
try {
if (!req.user.roles.includes("verifier")) {
return res
.status(403)
.json({ error: "Only verifiers can view host requests." });
}
const pendingUsers = await User.find({
"hostEligibilityStatus.status": "pending",
})
.select("-passwordHash")
.populate("institutionId", "name isVerified");
return res.json(pendingUsers);
} catch (err) {
logger.error("ListPendingHostRequests error:", err);
return res
.status(500)
.json({ error: "Server error listing host requests." });
}
}
// Helper to remove sensitive fields before sending user
function sanitizeUser(user) {
const obj = user.toObject();
delete obj.passwordHash;
return obj;
}
/**
* POST /forgot-password
* Initiate password reset: send email with reset token
*/
async function forgotPassword(req, res) {
try {
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email required." });
const user = await User.findOne({ email });
if (!user)
return res
.status(200)
.json({ message: "If the email exists, a reset link has been sent." }); // Don't reveal user existence
const token = crypto.randomBytes(32).toString("hex");
const isGoogleUser = user.passwordHash.includes("google_user_");
// Store token with user info
await redisClient.setEx(
`reset:${token}`,
3600,
JSON.stringify({
email,
isGoogleUser,
needsSetup: isGoogleUser,
}),
); // 1 hour expiry
// Send email with reset link
const resetUrl = `${process.env.FRONTEND_URL || "http://localhost:3000"}/reset-password?token=${token}`;
const subject = isGoogleUser ? "Set Up Your Password" : "Password Reset";
const text = isGoogleUser
? `Click the link below to set up your password:\n\n${resetUrl}\n\nIf you did not request this, please ignore this email.`
: `Click the link below to reset your password:\n\n${resetUrl}\n\nIf you did not request this, please ignore this email.`;
await emailService.sendMail({
to: user.email,
subject,
text,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">${subject}</h2>
<p>Hello ${user.name},</p>
<p>${isGoogleUser ? "Set up your password" : "Reset your password"} by clicking the button below:</p>
<a href="${resetUrl}" style="display: inline-block; background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0;">${isGoogleUser ? "Set Up Password" : "Reset Password"}</a>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666;">${resetUrl}</p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
<hr>
<p style="color: #666; font-size: 12px;">This is an automated message from CampVerse.</p>
</div>
`,
});
return res.status(200).json({
message: "If the email exists, a reset link has been sent.",
isGoogleUser,
});
} catch (err) {
logger.error("ForgotPassword error:", err);
return res
.status(500)
.json({ error: "Server error during password reset request." });
}
}
/**
* POST /reset-password
* Reset password using token
*/
async function resetPassword(req, res) {
try {
const { token, password } = req.body;
if (!token || !password)
return res
.status(400)
.json({ error: "Token and new password required." });
const storedData = await redisClient.get(`reset:${token}`);
if (!storedData)
return res.status(400).json({ error: "Invalid or expired token." });
let email, isGoogleUser, needsSetup;
try {
const parsed = JSON.parse(storedData);
email = parsed.email;
isGoogleUser = parsed.isGoogleUser;
needsSetup = parsed.needsSetup;
} catch (e) {
// Fallback for old format
email = storedData;
isGoogleUser = false;
needsSetup = false;
}
const user = await User.findOne({ email });
if (!user) return res.status(400).json({ error: "User not found." });
// Hash the new password
user.passwordHash = await bcrypt.hash(password, 10);
user.passwordSetup = true;
// If this was a Google user setting up password for the first time
if (isGoogleUser && needsSetup) {
user.googleLinked = true; // Ensure Google is marked as linked
user.authMethods = Array.isArray(user.authMethods)
? user.authMethods
: [];
if (!user.authMethods.includes("password"))
user.authMethods.push("password");
if (!user.primaryAuthMethod) user.primaryAuthMethod = "password";
}
await user.save();
await redisClient.del(`reset:${token}`);
const message =
isGoogleUser && needsSetup
? "Password set up successfully. You can now use both Google and email/password login."
: "Password reset successful.";
return res.status(200).json({
message,
isGoogleUser,
needsSetup,
});
} catch (err) {
logger.error("ResetPassword error:", err);
return res
.status(500)
.json({ error: "Server error during password reset." });
}
}
// ---------------- Settings: Notification Preferences ----------------
async function getMyNotificationPreferences(req, res) {
try {
const user = await User.findById(req.user.id).select(
"notificationPreferences",
);
if (!user) return res.status(404).json({ error: "User not found." });
return res.json(user.notificationPreferences || {});
} catch (err) {
logger.error("Get Notification Preferences error:", err);
return res
.status(500)
.json({ error: "Failed to fetch notification preferences." });
}
}
async function updateMyNotificationPreferences(req, res) {
try {
const allowedKeys = [
"rsvp",
"certificate",
"cohost",
"event_verification",
"host_request",
];
const updates = { email: {}, inApp: {} };
if (req.body && typeof req.body === "object") {
if (req.body.email && typeof req.body.email === "object") {
for (const key of allowedKeys) {
if (
key in req.body.email &&
typeof req.body.email[key] === "boolean"
) {
updates.email[key] = req.body.email[key];
}
}
}
if (req.body.inApp && typeof req.body.inApp === "object") {
for (const key of allowedKeys) {
if (
key in req.body.inApp &&
typeof req.body.inApp[key] === "boolean"
) {
updates.inApp[key] = req.body.inApp[key];
}
}
}
}
// Build Mongo update object using dot-notation for provided fields only
const mongoUpdate = {};
for (const [channel, channelUpdates] of Object.entries(updates)) {
for (const [k, v] of Object.entries(channelUpdates)) {
mongoUpdate[`notificationPreferences.${channel}.${k}`] = v;
}
}
if (Object.keys(mongoUpdate).length === 0) {
return res
.status(400)
.json({ error: "No valid notification preference fields to update." });
}
const updated = await User.findByIdAndUpdate(
req.user.id,
{ $set: mongoUpdate },
{ new: true },
).select("notificationPreferences");
if (!updated) return res.status(404).json({ error: "User not found." });
return res.json({
message: "Notification preferences updated.",
notificationPreferences: updated.notificationPreferences,
});
} catch (err) {
logger.error("Update Notification Preferences error:", err);
return res
.status(500)
.json({ error: "Failed to update notification preferences." });
}
}
// ---------------- Logout ----------------
async function logout(req, res) {
try {
const token = req.token;
if (token) {
// Add token to blacklist with expiration
const decoded = jwt.decode(token);
const exp = decoded.exp || Math.floor(Date.now() / 1000) + 3600; // Default 1 hour
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redisClient.setEx(`blacklist:${token}`, ttl, 'revoked');
logger.info(`Token blacklisted for user ${req.user.id}`);
}
}
res.json({ message: 'Logged out successfully.' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed.' });
}
}
// ---------------- Settings: Delete My Account (schedule) ----------------
async function deleteMe(req, res) {
try {
const user = await User.findById(req.user.id);
if (!user) return res.status(404).json({ error: "User not found." });
user.deletionRequestedAt = new Date();
user.deletionScheduledFor = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await user.save();
return res.json({
message:
"Account deletion requested. Your profile will be deleted in 30 days.",
});
} catch (err) {
logger.error("DeleteMe error:", err);
return res
.status(500)
.json({ error: "Server error requesting account deletion." });
}
}
// ---------------- Settings: Unlink Google Account ----------------
async function unlinkGoogleAccount(req, res) {
try {
const user = await User.findById(req.user.id);
if (!user) return res.status(404).json({ error: "User not found." });
// Ensure the user still has a valid password before unlinking Google
if (!user.passwordSetup) {
return res
.status(400)
.json({
error: "Please set a password first before unlinking Google.",
});
}
if (!user.googleLinked) {
return res.status(400).json({ error: "Google account is not linked." });
}
user.googleLinked = false;
if (Array.isArray(user.authMethods)) {
user.authMethods = user.authMethods.filter((m) => m !== "google");
}
await user.save();
return res.json({
message: "Google account unlinked successfully.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("UnlinkGoogleAccount error:", err);
return res.status(500).json({ error: "Failed to unlink Google account." });
}
}
// Update deleteUser: if user deletes self, mark for deletion in 30 days
async function deleteUser(req, res) {
try {
// If admin, allow immediate delete
if (req.user.roles.includes("platformAdmin")) {
const deleted = await User.findByIdAndDelete(req.params.id);
if (!deleted) return res.status(404).json({ error: "User not found." });
return res.json({ message: "User deleted." });
}
// If user is deleting self, mark for deletion in 30 days
if (req.user.id === req.params.id) {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found." });
user.deletionRequestedAt = new Date();
user.deletionScheduledFor = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000,
); // 30 days
await user.save();
return res.json({
message:
"Account deletion requested. Your profile will be deleted in 30 days.",
});
}
return res
.status(403)
.json({ error: "Forbidden: only admin or self can delete." });
} catch (err) {
logger.error("DeleteUser error:", err);
return res.status(500).json({ error: "Server error deleting user." });
}
}
// Referral and Badge Logic
async function trackReferral(req, res) {
try {
const { referrerId } = req.body;
if (!referrerId) {
return res.status(400).json({ error: "Referrer ID is required." });
}
// Check if referrer exists
const referrer = await User.findById(referrerId);
if (!referrer) {
return res.status(404).json({ error: "Referrer not found." });
}
// Update referrer's stats
referrer.referralStats.successfulSignups += 1;
await referrer.save();
// Award badge to referrer if they reach milestones
await awardReferralBadges(referrer);
return res.json({ message: "Referral tracked successfully." });
} catch (err) {
logger.error("Track referral error:", err);
return res.status(500).json({ error: "Server error tracking referral." });
}
}
async function awardReferralBadges(user) {
try {
const { successfulSignups } = user.referralStats;
// Award badges based on referral milestones
if (successfulSignups >= 10 && !user.badges.includes("Super Referrer")) {
user.badges.push("Super Referrer");
await user.save();
// Create achievement record
await Achievement.create({
userId: user._id,
title: "Super Referrer",
badgeIcon: "🏆",
points: 100,
earnedAt: new Date(),
});
} else if (
successfulSignups >= 5 &&
!user.badges.includes("Active Referrer")
) {
user.badges.push("Active Referrer");
await user.save();
await Achievement.create({
userId: user._id,
title: "Active Referrer",
badgeIcon: "⭐",
points: 50,
earnedAt: new Date(),
});
} else if (
successfulSignups >= 1 &&
!user.badges.includes("First Referral")
) {
user.badges.push("First Referral");
await user.save();
await Achievement.create({
userId: user._id,
title: "First Referral",
badgeIcon: "🎯",
points: 10,
earnedAt: new Date(),
});
}
} catch (err) {
logger.error("Award referral badges error:", err);
}
}
// Note: awardEventBadges removed (unused)
// Get user badges and achievements
async function getUserBadges(req, res) {
try {
const userId = req.params.id || req.user.id;
const user = await User.findById(userId).select("badges");
const achievements = await Achievement.find({ userId }).sort({
earnedAt: -1,
});
return res.json({
badges: user.badges,
achievements,
});
} catch (err) {
logger.error("Get user badges error:", err);
return res.status(500).json({ error: "Server error fetching badges." });
}
}
// Upload profile photo (multipart/form-data: field name 'photo')
async function uploadProfilePhotoHandler(req, res) {
try {
if (!req.file) return res.status(400).json({ error: "No file uploaded." });
const user = await User.findById(req.user.id);
if (!user) return res.status(404).json({ error: "User not found." });
// Delete old profile photo if exists (best-effort)
if (user.profilePhoto) {
try {
logger.info(`Attempting to delete old profile photo: ${user.profilePhoto}`);
const deleted = await deleteProfilePhoto(user.profilePhoto);
if (deleted) {
logger.info(`Successfully deleted old profile photo: ${user.profilePhoto}`);
} else {
logger.warn(`Failed to delete old profile photo: ${user.profilePhoto}`);
}
} catch (error) {
logger.error(`Error deleting old profile photo: ${user.profilePhoto}`, error);
// ignore deletion failures - don't break the upload process
}
}
// Upload new photo to storage
const url = await uploadProfilePhoto(
req.file.buffer,
req.file.originalname,
req.user.id,
req.file.mimetype,
);
user.profilePhoto = url;
await user.save();
return res.json({
message: "Profile photo updated (stored locally).",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("UploadProfilePhoto error:", err);
return res.status(500).json({ error: "Failed to upload profile photo." });
}
}
// Set institution for current user
async function setInstitutionForMe(req, res) {
try {
const { institutionId } = req.body || {};
if (!institutionId)
return res.status(400).json({ error: "institutionId is required." });
const Institution = require("../Models/Institution");
const institution = await Institution.findById(institutionId);
// Note: event badges awarding is deferred; remove unused function to satisfy linting.
const user = await User.findById(req.user.id);
if (!user) return res.status(404).json({ error: "User not found." });
user.institutionId = institution._id;
user.institutionVerificationStatus = institution.isVerified
? "verified"
: "pending";
await user.save();
return res.json({
message: "Institution updated.",
user: sanitizeUser(user),
});
} catch (err) {
logger.error("SetInstitutionForMe error:", err);
return res.status(500).json({ error: "Failed to set institution." });
}
}
// ---------------- Resend OTP ----------------
async function resendOtp(req, res) {
try {
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email is required." });
const tempStr = await redisClient.get(email);
if (!tempStr)
return res
.status(400)
.json({ error: "No pending registration found for this email." });
const tempData = JSON.parse(tempStr);
const otp = otpgenrater();
try {
await emailService.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: "Your Verification Code",
text: `Your verification code is: ${otp}. Please enter it within 5 minutes.`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">CampVerse Verification Code</h2>
<p>Hello ${tempData.name},</p>
<p>Your verification code is: <strong style="font-size: 24px; color: #007bff;">${otp}</strong></p>
<p>Please enter this code within 5 minutes to complete your registration.</p>
<p>If you didn't request this code, please ignore this email.</p>
<hr>
<p style="color: #666; font-size: 12px;">This is an automated message from CampVerse.</p>
</div>
`,
});
logger.info(`Email resent successfully to ${email}`);
} catch (emailError) {
logger.error("Email sending failed:", emailError.message);
return res
.status(500)
.json({
error: "Failed to send verification email. Please try again.",
});
}
tempData.otp = otp;
await redisClient.setEx(email, 600, JSON.stringify(tempData));
return res.status(200).json({ message: "OTP resent to email." });
} catch (err) {
logger.error("Resend OTP error:", err);
return res
.status(500)
.json({ error: "Server error during OTP resend. Please try again." });
}
}
/**
* Find user by email (for co-host nomination validation)
* Returns basic info: _id, name, email, canHost
*/
async function findUserByEmail(req, res) {
try {
const { email, domain } = req.query;
if (!email && !domain) {
return res.status(400).json({ error: 'Email or domain query parameter is required' });
}
let query = {};
if (email) {
query.email = email.toLowerCase();
} else if (domain) {
query.email = { $regex: `@${domain.replace('.', '\\.')}$`, $options: 'i' };
}
const users = await User.find(query).select('_id name email canHost profilePhoto').limit(10);
if (users.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
// If exact email was requested and found, return singular user for backward compatibility
if (email && users.length > 0) {
return res.status(200).json({
success: true,
user: users[0],
users
});
}
return res.status(200).json({
success: true,
users
});
} catch (error) {
logger.error('Error in findUserByEmail:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
// Get all users (admin only) with pagination and search
async function getAllUsers(req, res) {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const search = req.query.search || '';
const role = req.query.role || '';
const skip = (page - 1) * limit;
let query = {};
if (search) {
query = {
$or: [
{ name: { $regex: search, $options: 'i' } },
{ email: { $regex: search, $options: 'i' } }
]
};
}
if (role && role !== 'all') {
if (query.$or) {
query = {
$and: [
query,
{ roles: role }
]
};
} else {
query.roles = role;
}
}
const total = await User.countDocuments(query);
const users = await User.find(query)
.select('-passwordHash')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit);
return res.json({
users,
pagination: {
total,
page,
limit,
pages: Math.ceil(total / limit)
}
});
} catch (err) {
logger.error('GetAllUsers error:', err);
return res.status(500).json({ error: 'Server error fetching users.' });
}
}
// Update user roles (admin only)
async function updateUserRoles(req, res) {
try {
const { roles } = req.body;
if (!roles || !Array.isArray(roles)) {
return res.status(400).json({ error: 'Roles must be an array.' });
}
const user = await User.findByIdAndUpdate(
req.params.id,
{ roles },
{ new: true }
).select('-passwordHash');
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
return res.json({ message: 'User roles updated.', user });
} catch (err) {
logger.error('UpdateUserRoles error:', err);
return res.status(500).json({ error: 'Server error updating user roles.' });
}
}
module.exports = {
register,
verifyOtp,
login,
updatePreferences,
getMe,
updateMe,
getAllUsers,
getUserById,
updateUserById,
updateUserRoles,
deleteUser,
getUserCertificates,
getUserAchievements,
getUserEvents,
grantHostAccess,
grantVerifierAccess,
googleSignIn,
linkGoogleAccount,
setupPasswordForGoogleUser,
changePassword,
sendOtpForGoogleUser,
verifyOtpForGoogleUser,
getAuthStatus,
forgotPassword,
resetPassword,
getDashboard,
trackReferral,
getUserBadges,
requestHostAccess,
approveHostRequest,
rejectHostRequest,
listPendingHostRequests,
resendOtp,
uploadProfilePhoto: uploadProfilePhotoHandler,
setInstitutionForMe,
getMyNotificationPreferences,
updateMyNotificationPreferences,
deleteMe,
unlinkGoogleAccount,
logout,
findUserByEmail,
};