Skip to content
Merged

Dev #38

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified public/Coderush.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions public/site.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "CodeRush 2025",
"short_name": "CodeRush",
"icons": [
{
"src": "/Coderush.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/Coderush.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
37 changes: 27 additions & 10 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { states, validators, MEMBER_COUNT } from "@/lib/stateMachine";
import { appendToGoogleSheets } from "@/lib/googleSheets";
import { sendRegistrationEmail } from "@/lib/emailService";
import { Member } from "@/types/registration";
import { globalRateLimiter, getDuplicateErrorMessage, isDuplicateKeyError } from "@/lib/concurrencyHelpers";

type ReqBody = {
sessionId: string;
Expand All @@ -27,7 +28,7 @@ export async function POST(req: Request) {
console.log("🚀 Starting chat API request");
await dbConnect();
console.log("✅ Database connected");

let body: ReqBody;
try {
body = await req.json();
Expand All @@ -36,7 +37,7 @@ export async function POST(req: Request) {
console.error("Failed to parse request body:", error);
return NextResponse.json({ reply: "Invalid request format" }, { status: 400 });
}

const sessionId = body.sessionId;
const message = (body.message || "").trim();

Expand All @@ -45,6 +46,14 @@ export async function POST(req: Request) {
return NextResponse.json({ reply: "Missing sessionId" }, { status: 400 });
}

// Rate limiting - prevent abuse
if (!globalRateLimiter.check(sessionId)) {
console.warn("⚠️ Rate limit exceeded for session:", sessionId);
return NextResponse.json({
reply: "⚠️ Too many requests. Please wait a moment before trying again."
}, { status: 429 });
}

// find or create session
console.log("🔍 Looking for registration with sessionId:", sessionId);
let reg = await Registration.findOne({ sessionId });
Expand Down Expand Up @@ -687,17 +696,25 @@ export async function POST(req: Request) {

// default fallback prompt
return NextResponse.json({ reply: states[reg.state]?.prompt || "Okay." });

} catch (error) {
console.error("❌ API error:", error);

} catch (error: unknown) {
// Handle MongoDB duplicate key errors (race conditions)
if (isDuplicateKeyError(error)) {
console.error("⚠️ Duplicate key error detected:", (error as { keyPattern?: Record<string, unknown> }).keyPattern);
return NextResponse.json({
reply: getDuplicateErrorMessage(error)
});
}

// Handle other errors
console.error("❌ API Error:", error);
console.error("Error details:", {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : error,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : 'No stack trace'
});
return NextResponse.json(
{ reply: "Sorry, there was an error processing your request. Please try again." },
{ status: 500 }
);
return NextResponse.json({
reply: "❌ An error occurred while processing your request. Please try again."
}, { status: 500 });
}
}
Binary file removed src/app/favicon.ico
Binary file not shown.
Binary file added src/app/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 14 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,20 @@ const geistMono = Geist_Mono({
});

export const metadata: Metadata = {
title: "CodeRush 2025 - Registration",
description: "Register your team for CodeRush 2025 coding competition",
title: "CodeRush 2025",
description: "CodeRush 2025 - Where Ideas Ignite, Code Unites! Register your team for the ultimate coding competition.",
icons: {
icon: [
{ url: '/Coderush.png', type: 'image/png' },
{ url: '/Coderush.png', sizes: '32x32', type: 'image/png' },
{ url: '/Coderush.png', sizes: '16x16', type: 'image/png' },
],
apple: [
{ url: '/Coderush.png', sizes: '180x180', type: 'image/png' },
],
shortcut: '/Coderush.png',
},
manifest: '/site.webmanifest',
};

export default function RootLayout({
Expand Down
2 changes: 1 addition & 1 deletion src/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ const Hero = () => {
alt="CodeRush 2025"
width={8000}
height={4000}
className="w-auto h-auto max-w-full"
className="w-full max-w-[200px] sm:max-w-[250px] md:max-w-[300px] lg:max-w-[350px] h-auto"
priority
/>
</motion.div>
Expand Down
176 changes: 176 additions & 0 deletions src/lib/concurrencyHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Concurrency Helpers
* Utilities to handle race conditions and concurrent operations safely
*/

/**
* Retry an async operation with exponential backoff
* Useful for handling temporary conflicts or network issues
*/
export async function retryWithBackoff<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 100
): Promise<T> {
let lastError: Error | unknown;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error: unknown) {
lastError = error;

// Don't retry on duplicate key errors - these are permanent
if (typeof error === 'object' && error !== null && 'code' in error && (error as { code: number }).code === 11000) {
throw error;
}

// Don't retry on validation errors
if (error instanceof Error && error.name === 'ValidationError') {
throw error;
}

// If we've used all retries, throw the error
if (attempt === maxRetries) {
break;
}

// Calculate delay with exponential backoff and jitter
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 100;
console.log(`⏳ Retry attempt ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms`);
await sleep(delay);
}
}

throw lastError;
}

/**
* Sleep helper function
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

/**
* Check if an error is a MongoDB duplicate key error
*/
export function isDuplicateKeyError(error: unknown): boolean {
if (typeof error === 'object' && error !== null) {
const err = error as { code?: number; name?: string };
return err.code === 11000 || err.name === 'MongoServerError';
}
return false;
}

/**
* Extract field name from duplicate key error
*/
export function getDuplicateField(error: unknown): string | null {
if (!isDuplicateKeyError(error)) return null;

const err = error as { keyPattern?: Record<string, unknown> };
const keyPattern = err.keyPattern || {};
const field = Object.keys(keyPattern)[0];
return field || null;
}

/**
* Get user-friendly message for duplicate key error
*/
export function getDuplicateErrorMessage(error: unknown): string {
const field = getDuplicateField(error);

if (field === 'teamName' || field?.includes('teamName')) {
return "❌ This team name was just taken by another user. Please choose a different name.";
} else if (field?.includes('indexNumber')) {
return "❌ This index number was just registered by another team. Please use a different index number.";
} else if (field?.includes('email')) {
return "❌ This email was just registered by another team. Please use a different email address.";
} else if (field === 'sessionId') {
return "❌ Session conflict detected. Please refresh the page and try again.";
}

return "❌ Duplicate data detected. Please try again with different information.";
}

/**
* Atomic increment with retry
* Useful for counting team registrations safely
*/
export async function atomicIncrement(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
model: any,
filter: Record<string, unknown>,
field: string,
maxRetries: number = 3
): Promise<number> {
return retryWithBackoff(async () => {
const result = await model.findOneAndUpdate(
filter,
{ $inc: { [field]: 1 } },
{ new: true, upsert: true }
);
return result[field];
}, maxRetries);
}

/**
* Check uniqueness with lock
* Prevents race conditions when checking if a value already exists
*/
export async function checkUniqueWithLock(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
model: any,
field: string,
value: string,
excludeId?: string
): Promise<boolean> {
const query: Record<string, unknown> = { [field]: value };
if (excludeId) {
query._id = { $ne: excludeId };
}

const exists = await model.findOne(query);
return !exists;
}

/**
* Rate limiting helper
* Prevents too many requests from a single session
*/
export class RateLimiter {
private requests: Map<string, number[]> = new Map();
private maxRequests: number;
private windowMs: number;

constructor(maxRequests: number = 10, windowMs: number = 60000) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}

check(key: string): boolean {
const now = Date.now();
const requests = this.requests.get(key) || [];

// Remove old requests outside the time window
const validRequests = requests.filter(time => now - time < this.windowMs);

if (validRequests.length >= this.maxRequests) {
return false; // Rate limit exceeded
}

// Add current request
validRequests.push(now);
this.requests.set(key, validRequests);

return true; // Request allowed
}

reset(key: string): void {
this.requests.delete(key);
}
}

// Global rate limiter instance (10 requests per minute per session)
export const globalRateLimiter = new RateLimiter(10, 60000);
10 changes: 7 additions & 3 deletions src/lib/dbConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ export default async function dbConnect() {
if (!cached.promise) {
const opts = {
bufferCommands: false, // Disable buffering
maxPoolSize: 10, // Maintain up to 10 socket connections
minPoolSize: 2, // Maintain at least 2 socket connections
serverSelectionTimeoutMS: 5000, // Keep trying to send operations for 5 seconds
maxPoolSize: 100, // Handle up to 100 concurrent connections
minPoolSize: 10, // Maintain at least 10 connections ready
serverSelectionTimeoutMS: 10000, // 10 seconds timeout for server selection
socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity
maxIdleTimeMS: 30000, // Close idle connections after 30 seconds
retryWrites: true, // Automatically retry failed writes
retryReads: true, // Automatically retry failed reads
w: 'majority' as const, // Wait for majority of nodes to confirm writes
};

cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongooseInstance) => {
Expand Down
54 changes: 47 additions & 7 deletions src/models/Registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const memberSchema = new Schema<Member>({
});

const registrationSchema = new Schema<RegistrationDocument>({
sessionId: { type: String, unique: true, required: true },
sessionId: { type: String, required: true },
teamName: { type: String, required: false },
teamBatch: { type: String, required: false },
members: { type: [memberSchema], default: [] },
Expand All @@ -38,16 +38,56 @@ const registrationSchema = new Schema<RegistrationDocument>({
},
});

// Add compound index that only applies uniqueness when teamName exists
// ============================================
// INDEXES FOR CONCURRENCY SAFETY & PERFORMANCE
// ============================================

// 1. Unique team name index (case-insensitive) - only for completed registrations
registrationSchema.index(
{ teamName: 1, state: 1 },
{
unique: true,
partialFilterExpression: {
state: "DONE",
teamName: { $exists: true, $ne: null }
},
collation: { locale: 'en', strength: 2 } // Case-insensitive
}
);

// 2. Unique index numbers - only for completed registrations
registrationSchema.index(
{ 'members.indexNumber': 1 },
{
unique: true,
partialFilterExpression: { state: "DONE" }
}
);

// 3. Unique emails (case-insensitive) - only for completed registrations
registrationSchema.index(
{ teamName: 1 },
{
unique: true,
sparse: true,
partialFilterExpression: { teamName: { $exists: true, $ne: null } }
{ 'members.email': 1 },
{
unique: true,
partialFilterExpression: { state: "DONE" },
collation: { locale: 'en', strength: 2 } // Case-insensitive
}
);

// 4. Index for faster session lookups
registrationSchema.index({ sessionId: 1 }, { unique: true });

// 5. Index for counting completed teams efficiently
registrationSchema.index({ state: 1, createdAt: -1 });

// 6. Compound index for faster queries
registrationSchema.index({ state: 1, teamName: 1 });

// ============================================
// ADD VERSION FIELD FOR OPTIMISTIC LOCKING
// ============================================
registrationSchema.set('versionKey', '__v');

const Registration: Model<RegistrationDocument> =
mongoose.models.Registration ||
mongoose.model<RegistrationDocument>("Registration", registrationSchema);
Expand Down
Loading