diff --git a/backend/package.json b/backend/package.json
index 86c46ee..ca06eb8 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -14,6 +14,7 @@
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"cors": "^2.8.5",
- "node-fetch": "^3.3.2"
+ "node-fetch": "^3.3.2",
+ "express-rate-limit": "^7.1.5"
}
}
diff --git a/backend/src/config.js b/backend/src/config.js
new file mode 100644
index 0000000..71c7336
--- /dev/null
+++ b/backend/src/config.js
@@ -0,0 +1,60 @@
+/**
+ * Environment configuration and validation
+ */
+
+const requiredEnvVars = [
+ 'JWT_SECRET',
+ 'DB_HOST',
+ 'DB_USER',
+ 'DB_PASSWORD',
+ 'DB_NAME',
+ 'TMDB_API_KEY'
+];
+
+export function validateConfig() {
+ const missing = [];
+
+ for (const varName of requiredEnvVars) {
+ if (!process.env[varName]) {
+ missing.push(varName);
+ }
+ }
+
+ if (missing.length > 0) {
+ console.error('❌ Missing required environment variables:');
+ missing.forEach(varName => {
+ console.error(` - ${varName}`);
+ });
+ console.error('\nPlease set these in your .env file or environment.');
+ process.exit(1);
+ }
+
+ // Validate JWT_SECRET is strong enough
+ if (process.env.JWT_SECRET.length < 32) {
+ console.error('❌ JWT_SECRET must be at least 32 characters long for security.');
+ process.exit(1);
+ }
+
+ console.log('✅ Environment configuration validated');
+}
+
+export const config = {
+ jwt: {
+ secret: process.env.JWT_SECRET,
+ expiresIn: '7d'
+ },
+ db: {
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_NAME,
+ port: parseInt(process.env.DB_PORT || '3306', 10)
+ },
+ tmdb: {
+ apiKey: process.env.TMDB_API_KEY,
+ baseUrl: 'https://api.themoviedb.org/3'
+ },
+ server: {
+ port: parseInt(process.env.PORT || '3000', 10)
+ }
+};
diff --git a/backend/src/index.js b/backend/src/index.js
index 5185e6a..98a5deb 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -4,7 +4,10 @@ import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
+import rateLimit from 'express-rate-limit';
import { initDB } from './db/index.js';
+import { validateConfig, config } from './config.js';
+import { errorHandler } from './middleware/errorHandler.js';
import authRoutes from './routes/auth.js';
import challengeRoutes from './routes/challenges.js';
import predictionRoutes from './routes/predictions.js';
@@ -14,22 +17,50 @@ import leaderboardRoutes from './routes/leaderboard.js';
dotenv.config();
+// Validate environment configuration
+validateConfig();
+
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
+// Rate limiting
+const authLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 5, // 5 requests per window
+ message: { error: 'Too many authentication attempts, please try again later.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+const apiLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 100, // 100 requests per window
+ message: { error: 'Too many requests, please try again later.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+const tmdbLimiter = rateLimit({
+ windowMs: 60 * 1000, // 1 minute
+ max: 20, // 20 requests per minute
+ message: { error: 'Too many search requests, please slow down.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
// Middleware
app.use(cors());
app.use(express.json());
// API Routes
-app.use('/api/auth', authRoutes);
-app.use('/api/challenges', challengeRoutes);
-app.use('/api/predictions', predictionRoutes);
-app.use('/api/friends', friendRoutes);
-app.use('/api/tmdb', tmdbRoutes);
-app.use('/api/leaderboard', leaderboardRoutes);
+app.use('/api/auth', authLimiter, authRoutes);
+app.use('/api/challenges', apiLimiter, challengeRoutes);
+app.use('/api/predictions', apiLimiter, predictionRoutes);
+app.use('/api/friends', apiLimiter, friendRoutes);
+app.use('/api/tmdb', tmdbLimiter, tmdbRoutes);
+app.use('/api/leaderboard', apiLimiter, leaderboardRoutes);
// Health check
app.get('/api/health', (req, res) => {
@@ -57,7 +88,10 @@ if (frontendExists) {
});
}
-const PORT = process.env.PORT || 4000;
+// Error handling middleware (must be last)
+app.use(errorHandler);
+
+const PORT = config.server.port;
// Initialize database and start server
initDB()
diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js
new file mode 100644
index 0000000..57e930b
--- /dev/null
+++ b/backend/src/middleware/errorHandler.js
@@ -0,0 +1,58 @@
+/**
+ * Centralized error handling middleware
+ */
+
+export class AppError extends Error {
+ constructor(message, statusCode = 500) {
+ super(message);
+ this.statusCode = statusCode;
+ this.isOperational = true;
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+export function errorHandler(err, req, res, next) {
+ let { statusCode = 500, message } = err;
+
+ // Log error for debugging
+ if (process.env.NODE_ENV !== 'production') {
+ console.error('Error:', {
+ message: err.message,
+ stack: err.stack,
+ statusCode,
+ path: req.path,
+ method: req.method
+ });
+ } else {
+ console.error('Error:', err.message);
+ }
+
+ // Handle specific error types
+ if (err.code === 'ER_DUP_ENTRY') {
+ statusCode = 409;
+ message = 'A record with this information already exists';
+ }
+
+ if (err.name === 'JsonWebTokenError') {
+ statusCode = 401;
+ message = 'Invalid token';
+ }
+
+ if (err.name === 'TokenExpiredError') {
+ statusCode = 401;
+ message = 'Token expired';
+ }
+
+ // Send error response
+ res.status(statusCode).json({
+ error: message,
+ ...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
+ });
+}
+
+// Async handler wrapper to catch errors in async routes
+export function asyncHandler(fn) {
+ return (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+ };
+}
diff --git a/frontend/package.json b/frontend/package.json
index b7ac290..4e3e246 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,7 +10,8 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router-dom": "^6.20.0"
+ "react-router-dom": "^6.20.0",
+ "react-hot-toast": "^2.4.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx
new file mode 100644
index 0000000..a35149c
--- /dev/null
+++ b/frontend/src/components/ErrorBoundary.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+
+class ErrorBoundary extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ console.error('Error caught by boundary:', error, errorInfo);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
Oops! Something went wrong
+
+ We're sorry, but something unexpected happened. Please refresh the page to try again.
+
+
+ {process.env.NODE_ENV === 'development' && this.state.error && (
+
+ Error Details
+
+ {this.state.error.toString()}
+
+
+ )}
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/frontend/src/hooks/useClickOutside.js b/frontend/src/hooks/useClickOutside.js
new file mode 100644
index 0000000..75d899a
--- /dev/null
+++ b/frontend/src/hooks/useClickOutside.js
@@ -0,0 +1,20 @@
+import { useEffect } from 'react';
+
+export function useClickOutside(ref, handler) {
+ useEffect(() => {
+ const listener = (event) => {
+ if (!ref.current || ref.current.contains(event.target)) {
+ return;
+ }
+ handler(event);
+ };
+
+ document.addEventListener('mousedown', listener);
+ document.addEventListener('touchstart', listener);
+
+ return () => {
+ document.removeEventListener('mousedown', listener);
+ document.removeEventListener('touchstart', listener);
+ };
+ }, [ref, handler]);
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index cd4d0af..ccebeee 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
+import { Toaster } from 'react-hot-toast';
import { AuthProvider, useAuth } from './AuthContext';
import Login from './pages/Login';
import Register from './pages/Register';
@@ -9,6 +10,7 @@ import ChallengeDetail from './pages/ChallengeDetail';
import Profile from './pages/Profile';
import Friends from './pages/Friends';
import Leaderboard from './pages/Leaderboard';
+import ErrorBoundary from './components/ErrorBoundary';
import './App.css';
function ProtectedRoute({ children }) {
@@ -50,21 +52,46 @@ function Header() {
function App() {
return (
-
-
-
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
);
}
diff --git a/frontend/src/pages/ChallengeDetail.jsx b/frontend/src/pages/ChallengeDetail.jsx
index 482d850..41b490c 100644
--- a/frontend/src/pages/ChallengeDetail.jsx
+++ b/frontend/src/pages/ChallengeDetail.jsx
@@ -1,7 +1,9 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
+import toast from 'react-hot-toast';
import { useAuth } from '../AuthContext';
import api from '../api';
+import { useClickOutside } from '../hooks/useClickOutside';
export default function ChallengeDetail() {
const { id } = useParams();
@@ -15,6 +17,11 @@ export default function ChallengeDetail() {
const [showInvite, setShowInvite] = useState(false);
const [loading, setLoading] = useState(true);
const [searchTimeout, setSearchTimeout] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [validating, setValidating] = useState(null);
+ const searchRef = useRef(null);
+
+ useClickOutside(searchRef, () => setSearchResults([]));
useEffect(() => {
loadChallenge();
@@ -55,25 +62,33 @@ export default function ChallengeDetail() {
e.preventDefault();
if (!newPrediction.trim()) return;
+ setSubmitting(true);
try {
await api.createPrediction({
challenge_id: id,
content: newPrediction
});
+ toast.success('Prediction submitted!');
setNewPrediction('');
await loadPredictions();
} catch (err) {
- alert('Failed to create prediction: ' + err.message);
+ toast.error('Failed to create prediction: ' + err.message);
+ } finally {
+ setSubmitting(false);
}
};
const handleValidate = async (predictionId, status) => {
+ setValidating(predictionId);
try {
await api.validatePrediction(predictionId, status);
+ toast.success(status === 'validated' ? 'Prediction validated!' : 'Prediction invalidated');
await loadPredictions();
await loadLeaderboard();
} catch (err) {
- alert('Failed to validate: ' + err.message);
+ toast.error('Failed to validate: ' + err.message);
+ } finally {
+ setValidating(null);
}
};
@@ -106,11 +121,11 @@ export default function ChallengeDetail() {
const handleInvite = async (userId) => {
try {
await api.inviteToChallenge(id, { user_ids: [userId] });
+ toast.success('Invitation sent!');
setInviteQuery('');
setSearchResults([]);
- alert('Invitation sent!');
} catch (err) {
- alert('Failed to send invite: ' + err.message);
+ toast.error('Failed to send invite: ' + err.message);
}
};
@@ -154,7 +169,7 @@ export default function ChallengeDetail() {
{/* Invite Section */}
{showInvite && (
-
+
Invite Someone
setNewPrediction(e.target.value)}
style={{ minHeight: '80px' }}
/>
-
@@ -261,14 +276,16 @@ export default function ChallengeDetail() {
handleValidate(pred.id, 'validated')}
+ disabled={validating === pred.id}
>
- ✓ Validate
+ {validating === pred.id ? '...' : '✓ Validate'}
handleValidate(pred.id, 'invalidated')}
+ disabled={validating === pred.id}
>
- ✗ Invalidate
+ {validating === pred.id ? '...' : '✗ Invalidate'}
>
)}
diff --git a/frontend/src/pages/ChallengeList.jsx b/frontend/src/pages/ChallengeList.jsx
index 10fee64..96d882d 100644
--- a/frontend/src/pages/ChallengeList.jsx
+++ b/frontend/src/pages/ChallengeList.jsx
@@ -1,6 +1,8 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
+import toast from 'react-hot-toast';
import api from '../api';
+import { useClickOutside } from '../hooks/useClickOutside';
export default function ChallengeList() {
const [challenges, setChallenges] = useState([]);
@@ -10,6 +12,10 @@ export default function ChallengeList() {
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [searchTimeout, setSearchTimeout] = useState(null);
+ const [respondingTo, setRespondingTo] = useState(null);
+ const searchRef = useRef(null);
+
+ useClickOutside(searchRef, () => setShowResults([]));
useEffect(() => {
loadChallenges();
@@ -31,11 +37,15 @@ export default function ChallengeList() {
};
const handleRespondToInvite = async (challengeId, status) => {
+ setRespondingTo(challengeId);
try {
await api.respondToChallenge(challengeId, status);
+ toast.success(status === 'accepted' ? 'Challenge accepted!' : 'Challenge declined');
await loadChallenges();
} catch (err) {
- alert('Failed to respond: ' + err.message);
+ toast.error('Failed to respond: ' + err.message);
+ } finally {
+ setRespondingTo(null);
}
};
@@ -72,18 +82,19 @@ export default function ChallengeList() {
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
: null;
- const result = await api.createChallenge({
+ await api.createChallenge({
title: show.title,
cover_image_url: coverImage,
tmdb_id: show.id,
media_type: show.media_type
});
+ toast.success('Challenge created!');
setSearchQuery('');
setShowResults([]);
await loadChallenges();
} catch (err) {
- alert('Failed to create challenge: ' + err.message);
+ toast.error('Failed to create challenge: ' + err.message);
} finally {
setCreating(false);
}
@@ -124,14 +135,16 @@ export default function ChallengeList() {
handleRespondToInvite(challenge.id, 'accepted')}
+ disabled={respondingTo === challenge.id}
>
- Accept
+ {respondingTo === challenge.id ? '...' : 'Accept'}
handleRespondToInvite(challenge.id, 'rejected')}
+ disabled={respondingTo === challenge.id}
>
- Decline
+ {respondingTo === challenge.id ? '...' : 'Decline'}
@@ -141,7 +154,7 @@ export default function ChallengeList() {
)}
{/* Search/Create */}
-
+
setSearchResults([]));
useEffect(() => {
loadData();
@@ -57,22 +64,29 @@ export default function Friends() {
};
const handleSendRequest = async (userId) => {
+ setSending(userId);
try {
await api.sendFriendRequest(userId);
setSearchQuery('');
setSearchResults([]);
- alert('Friend request sent!');
+ toast.success('Friend request sent!');
} catch (err) {
- alert('Failed to send request: ' + err.message);
+ toast.error('Failed to send request: ' + err.message);
+ } finally {
+ setSending(null);
}
};
const handleRespond = async (requestId, status) => {
+ setResponding(requestId);
try {
await api.respondToFriendRequest(requestId, status);
+ toast.success(status === 'accepted' ? 'Friend request accepted!' : 'Friend request declined');
await loadData();
} catch (err) {
- alert('Failed to respond: ' + err.message);
+ toast.error('Failed to respond: ' + err.message);
+ } finally {
+ setResponding(null);
}
};
@@ -86,7 +100,7 @@ export default function Friends() {
Friends
{/* Search */}
-
+
Add Friends
handleSendRequest(user.id)}
+ disabled={sending === user.id}
>
- Add Friend
+ {sending === user.id ? 'Sending...' : 'Add Friend'}
))}
@@ -151,14 +166,16 @@ export default function Friends() {
handleRespond(req.id, 'accepted')}
+ disabled={responding === req.id}
>
- Accept
+ {responding === req.id ? '...' : 'Accept'}
handleRespond(req.id, 'rejected')}
+ disabled={responding === req.id}
>
- Reject
+ {responding === req.id ? '...' : 'Reject'}
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index b5d7ed2..daa1b9c 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/Login.jsx
@@ -1,25 +1,25 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
+import toast from 'react-hot-toast';
import { useAuth } from '../AuthContext';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
- const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
- setError('');
setLoading(true);
try {
await login(email, password);
+ toast.success('Welcome back!');
navigate('/challenges');
} catch (err) {
- setError(err.message);
+ toast.error(err.message || 'Login failed');
} finally {
setLoading(false);
}
@@ -50,7 +50,6 @@ export default function Login() {
required
/>
- {error && {error}
}
{loading ? 'Logging in...' : 'Login'}
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx
index 05c3807..c377125 100644
--- a/frontend/src/pages/Register.jsx
+++ b/frontend/src/pages/Register.jsx
@@ -1,26 +1,26 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
+import toast from 'react-hot-toast';
import { useAuth } from '../AuthContext';
export default function Register() {
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
- const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
- setError('');
setLoading(true);
try {
await register(email, username, password);
+ toast.success('Account created successfully!');
navigate('/challenges');
} catch (err) {
- setError(err.message);
+ toast.error(err.message || 'Registration failed');
} finally {
setLoading(false);
}
@@ -62,7 +62,6 @@ export default function Register() {
minLength={6}
/>
- {error && {error}
}
{loading ? 'Creating account...' : 'Register'}