Skip to content

Commit a71f148

Browse files
committed
feat: Enhance user registration process with transaction support and OTP generation
feat: Implement navigation utility for axios interceptors to improve error handling refactor: Optimize LeetCode and GFG data updates with change detection fix: Ensure proper rate limiting for registration requests
1 parent fb53b04 commit a71f148

File tree

6 files changed

+167
-88
lines changed

6 files changed

+167
-88
lines changed

client-test/src/App.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { Navigate, BrowserRouter as Router, Routes, Route, useLocation } from 'react-router-dom';
1+
import { Navigate, BrowserRouter as Router, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
22
import { Toaster } from 'react-hot-toast';
3+
import { useEffect } from 'react';
4+
import { setNavigateFunction } from './lib/axios';
35
import Home from './components/Home';
46
import LoginPage from './components/Login';
57
import RegisterPage from './components/SignUp';
@@ -107,6 +109,12 @@ function AdminApp() {
107109

108110
function App() {
109111
const location = useLocation();
112+
const navigate = useNavigate();
113+
114+
// Set up navigation function for axios interceptors
115+
useEffect(() => {
116+
setNavigateFunction(navigate);
117+
}, [navigate]);
110118

111119
// Render AdminApp or UserApp based on path
112120
return location.pathname.startsWith("/codingclubadmin") ? (

client-test/src/lib/axios.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
import axios, { AxiosError } from 'axios';
22
import toast from 'react-hot-toast';
3+
import { NavigateFunction } from 'react-router-dom';
4+
5+
// Navigation utility for use in interceptors
6+
let navigateFunction: NavigateFunction | null = null;
7+
8+
export const setNavigateFunction = (navigate: NavigateFunction) => {
9+
navigateFunction = navigate;
10+
};
11+
12+
const navigateTo = (path: string) => {
13+
if (navigateFunction) {
14+
navigateFunction(path);
15+
} else {
16+
// Fallback to window.location if navigate function not set
17+
window.location.href = path;
18+
}
19+
};
320

421
// API Error Response interface
522
interface ApiErrorResponse {
@@ -72,7 +89,7 @@ axiosInstance.interceptors.response.use(
7289
redirectUrl: window.location.pathname
7390
}));
7491
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
75-
window.location.href = '/unauthorized';
92+
navigateTo('/unauthorized');
7693
}
7794
break;
7895
case 403:
@@ -82,7 +99,7 @@ axiosInstance.interceptors.response.use(
8299
case 404:
83100
// For API 404s, show toast unless it's a navigation request
84101
if (!config?.url?.includes('/api/')) {
85-
window.location.href = '/404';
102+
navigateTo('/404');
86103
} else {
87104
toast.error('The requested resource was not found');
88105
}
@@ -102,7 +119,7 @@ axiosInstance.interceptors.response.use(
102119
}));
103120
toast.error('Server error. Redirecting to error page...');
104121
setTimeout(() => {
105-
window.location.href = '/server-error';
122+
navigateTo('/server-error');
106123
}, 1500);
107124
break;
108125
default:
@@ -116,7 +133,7 @@ axiosInstance.interceptors.response.use(
116133
}));
117134
toast.error('Network error. Redirecting to error page...');
118135
setTimeout(() => {
119-
window.location.href = '/network-error';
136+
navigateTo('/network-error');
120137
}, 1500);
121138
} else {
122139
toast.error('An unexpected error occurred');

server/controllers/authController.js

Lines changed: 103 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -83,77 +83,118 @@ export const registerUser = async (req, res) => {
8383
return res.status(400).json({ error: "Username must start with letters and contain only lowercase letters, numbers, and underscores" });
8484
}
8585

86-
// Use single query to check all unique constraints atomically
87-
const [existingUser, existingUsername, existingRegNo] = await Promise.all([
88-
User.findOne({ email: trimmedEmail }),
89-
User.findOne({ username: trimmedUsername }),
90-
User.findOne({ RegistrationNumber: trimmedRegNo })
91-
]);
92-
93-
if (existingUser && existingUser.isVerified) {
94-
return res.status(400).json({ error: "Account already exists, please login" });
95-
}
86+
// Single query to check all unique constraints atomically within transaction
87+
const session = await mongoose.startSession();
88+
session.startTransaction();
89+
90+
try {
91+
const [existingUser, existingUsername, existingRegNo] = await Promise.all([
92+
User.findOne({ email: trimmedEmail }).session(session),
93+
User.findOne({ username: trimmedUsername }).session(session),
94+
User.findOne({ RegistrationNumber: trimmedRegNo }).session(session)
95+
]);
96+
97+
if (existingUser && existingUser.isVerified) {
98+
await session.abortTransaction();
99+
session.endSession();
100+
return res.status(400).json({ error: "Account already exists, please login" });
101+
}
96102

97-
if (existingUsername && existingUsername.isVerified) {
98-
return res.status(400).json({ error: "Username already exists, please choose another" });
99-
}
103+
if (existingUsername && existingUsername.isVerified) {
104+
await session.abortTransaction();
105+
session.endSession();
106+
return res.status(400).json({ error: "Username already exists, please choose another" });
107+
}
100108

101-
if (existingRegNo && existingRegNo.isVerified) {
102-
return res.status(400).json({ error: "Registration number already exists" });
103-
}
109+
if (existingRegNo && existingRegNo.isVerified) {
110+
await session.abortTransaction();
111+
session.endSession();
112+
return res.status(400).json({ error: "Registration number already exists" });
113+
}
104114

105-
const salt = await bcrypt.genSalt(10);
106-
const hashedPassword = await bcrypt.hash(password, salt);
115+
const salt = await bcrypt.genSalt(10);
116+
const hashedPassword = await bcrypt.hash(password, salt);
117+
118+
// Generate a 6-digit OTP
119+
const otp = crypto.randomInt(100000, 999999).toString();
120+
const nowIST = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Kolkata' }));
121+
const otpExpires = nowIST.getTime() + 5 * 60 * 1000; // OTP expires in 5 minutes
122+
123+
// Use upsert with atomic operation to prevent race conditions
124+
await User.findOneAndUpdate(
125+
{ email: trimmedEmail },
126+
{
127+
$set: {
128+
email: trimmedEmail,
129+
name: trimmedName,
130+
username: trimmedUsername,
131+
password: hashedPassword,
132+
RegistrationNumber: trimmedRegNo,
133+
branch: trimmedBranch,
134+
collegeName: trimmedCollege,
135+
isAffiliate: isAffiliate || false,
136+
otp,
137+
otpExpires,
138+
isVerified: false,
139+
}
140+
},
141+
{
142+
upsert: true,
143+
new: true,
144+
setDefaultsOnInsert: true,
145+
session
146+
}
147+
);
107148

108-
// Generate a 6-digit OTP
109-
const otp = crypto.randomInt(100000, 999999).toString();
110-
const nowIST = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Kolkata' }));
111-
const otpExpires = nowIST.getTime() + 5 * 60 * 1000; // OTP expires in 5 minutes
149+
await session.commitTransaction();
150+
session.endSession();
112151

113-
// Use upsert with atomic operation to prevent race conditions
114-
await User.findOneAndUpdate(
115-
{ email: trimmedEmail },
116-
{
117-
$set: {
118-
email: trimmedEmail,
119-
name: trimmedName,
120-
username: trimmedUsername,
121-
password: hashedPassword,
122-
RegistrationNumber: trimmedRegNo,
123-
branch: trimmedBranch,
124-
collegeName: trimmedCollege,
125-
isAffiliate: isAffiliate || false,
126-
otp,
127-
otpExpires,
128-
isVerified: false,
129-
}
130-
},
131-
{
132-
upsert: true,
133-
new: true,
134-
// Prevent duplicate key errors during concurrent registrations
135-
setDefaultsOnInsert: true
136-
}
137-
);
152+
// Send OTP to user's email
153+
await sendOTPEmail(trimmedEmail, otp);
138154

139-
// Send OTP to user's email
140-
await sendOTPEmail(trimmedEmail, otp);
155+
auditService.authEvent('registration_otp_sent', {
156+
requestId: req.auditContext?.requestId,
157+
email: trimmedEmail,
158+
username: trimmedUsername,
159+
collegeName: trimmedCollege,
160+
branch: trimmedBranch
161+
});
141162

142-
auditService.authEvent('registration_otp_sent', {
143-
requestId: req.auditContext?.requestId,
144-
email: trimmedEmail,
145-
username: trimmedUsername,
146-
collegeName: trimmedCollege,
147-
branch: trimmedBranch
148-
});
163+
audit.complete({
164+
email: trimmedEmail,
165+
username: trimmedUsername,
166+
otpSent: true
167+
});
149168

150-
audit.complete({
151-
email: trimmedEmail,
152-
username: trimmedUsername,
153-
otpSent: true
154-
});
169+
res.status(201).json({ message: "OTP sent to email. Verify to complete registration." });
170+
171+
} catch (error) {
172+
await session.abortTransaction();
173+
session.endSession();
174+
175+
if (error.code === 11000) { // MongoDB duplicate key error
176+
const field = Object.keys(error.keyPattern || {})[0];
177+
const fieldName = field === 'username' ? 'Username' :
178+
field === 'RegistrationNumber' ? 'Registration number' : 'Email';
179+
auditService.authEvent('registration_duplicate_error', {
180+
requestId: req.auditContext?.requestId,
181+
email: trimmedEmail,
182+
duplicateField: field,
183+
error: error.message
184+
});
185+
return res.status(400).json({ error: `${fieldName} already exists` });
186+
}
187+
188+
auditService.error('Registration failed', error, {
189+
requestId: req.auditContext?.requestId,
190+
email: req.body.email,
191+
username: req.body.username,
192+
ip: req.ip
193+
});
155194

156-
res.status(201).json({ message: "OTP sent to email. Verify to complete registration." });
195+
audit.error(error);
196+
res.status(500).json({ error: error.message });
197+
}
157198
} catch (error) {
158199
auditService.error('Registration failed', error, {
159200
requestId: req.auditContext?.requestId,

server/controllers/platformsController.js

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,29 @@ export const leetcodeData = async (req, res) => {
4949
const rank = responseData?.matchedUser?.profile?.ranking || user.leetCode.rank || 0;
5050
const stats = responseData?.matchedUser?.submitStats?.acSubmissionNum || [];
5151
const totalSolved = stats.find(s => s.difficulty === "All")?.count || user.leetCode.solved || 0;
52+
// Check if data actually changed before updating
53+
const hasChanged =
54+
user.leetCode.rating !== rating ||
55+
user.leetCode.rank !== rank ||
56+
user.leetCode.solved !== totalSolved;
57+
58+
if (!hasChanged) {
59+
return res.json({
60+
success: true,
61+
message: "LeetCode data is already up to date",
62+
modified: false
63+
});
64+
}
65+
5266
// Use atomic update with user ID instead of username lookup to prevent race conditions
5367
const updateResult = await User.updateOne(
54-
{
55-
_id: user._id,
56-
// Only update if the data has actually changed to prevent unnecessary writes
57-
$or: [
58-
{ 'leetCode.rating': { $ne: rating } },
59-
{ 'leetCode.rank': { $ne: rank } },
60-
{ 'leetCode.solved': { $ne: totalSolved } }
61-
]
62-
},
68+
{ _id: user._id },
6369
{
6470
$set: {
6571
'leetCode.username': username,
6672
'leetCode.rating': rating,
6773
'leetCode.rank': rank,
6874
'leetCode.solved': totalSolved,
69-
'leetCode.lastUpdated': new Date()
7075
}
7176
}
7277
);
@@ -99,23 +104,29 @@ export const geeksforgeeksData = async (req, res) => {
99104
const instituteRank = response.institute_rank || 0;
100105
const rating = response.rating || 0;
101106

107+
// Check if data actually changed before updating
108+
const hasChanged =
109+
user.gfg.solved !== totalSolved ||
110+
user.gfg.rank !== instituteRank ||
111+
user.gfg.rating !== rating;
112+
113+
if (!hasChanged) {
114+
return res.json({
115+
success: true,
116+
message: "GFG data is already up to date",
117+
modified: false
118+
});
119+
}
120+
102121
// Use atomic update with change detection
103122
const updateResult = await User.updateOne(
104-
{
105-
_id: user._id,
106-
$or: [
107-
{ 'gfg.solved': { $ne: totalSolved } },
108-
{ 'gfg.rank': { $ne: instituteRank } },
109-
{ 'gfg.rating': { $ne: rating } }
110-
]
111-
},
123+
{ _id: user._id },
112124
{
113125
$set: {
114126
'gfg.username': username,
115127
'gfg.solved': totalSolved,
116128
'gfg.rank': instituteRank,
117129
'gfg.rating': rating,
118-
'gfg.lastUpdated': new Date()
119130
}
120131
}
121132
);

server/lib/db.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import mongoose from "mongoose"
2+
import auditService from "../services/auditService.js";
23
const { default: cacheService } = await import("../services/cacheService.js");
34
const { warmupLeaderboardCache } = await import("../utils/leaderBoardCache.js");
45

@@ -30,7 +31,8 @@ const connectDB = async () => {
3031
console.error('Failed to warmup leaderboard cache:', error.message);
3132
}
3233
} else if (attempt < maxAttempts) {
33-
setTimeout(() => waitForRedisAndWarmup(attempt + 1), delay);
34+
await new Promise(resolve => setTimeout(resolve, delay));
35+
return waitForRedisAndWarmup(attempt + 1);
3436
} else {
3537
console.error('Redis was not ready after maximum attempts. Skipping leaderboard cache warmup.');
3638
}

server/middleware/rateLimiter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,4 @@ export const rateLimitMiddleware = (maxRequests = MAX_REQUESTS_PER_WINDOW, windo
126126
// Specific rate limiters for different operations
127127
export const challengeUpdateRateLimit = rateLimitMiddleware(5, 60000); // 5 requests per minute for challenge updates
128128
export const platformUpdateRateLimit = rateLimitMiddleware(3, 120000); // 3 requests per 2 minutes for platform updates
129-
export const registrationRateLimit = rateLimitMiddleware(3, 60000); // 5 registrations per minute (per IP/user) - prevents spam but allows legitimate usage
129+
export const registrationRateLimit = rateLimitMiddleware(3, 60000); // 3 registrations per minute (per IP/user) - prevents spam but allows legitimate usage

0 commit comments

Comments
 (0)