From efa1ea3b453a87dcc9b4cd1389b394970d2fab04 Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Thu, 29 Jan 2026 02:16:24 -0500 Subject: [PATCH] live updates --- backend/package.json | 3 +- backend/src/index.js | 9 +- backend/src/routes/challenges.js | 72 ++++++++-------- backend/src/routes/friends.js | 17 ++++ backend/src/routes/predictions.js | 17 ++++ backend/src/sockets/index.js | 111 +++++++++++++++++++++++++ frontend/package.json | 3 +- frontend/src/SocketContext.jsx | 91 ++++++++++++++++++++ frontend/src/main.jsx | 67 ++++++++------- frontend/src/pages/ChallengeDetail.jsx | 55 ++++++++++++ frontend/src/pages/ChallengeList.jsx | 18 ++++ frontend/src/pages/Friends.jsx | 29 +++++++ 12 files changed, 423 insertions(+), 69 deletions(-) create mode 100644 backend/src/sockets/index.js create mode 100644 frontend/src/SocketContext.jsx diff --git a/backend/package.json b/backend/package.json index ca06eb8..ed88056 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "dotenv": "^16.4.5", "cors": "^2.8.5", "node-fetch": "^3.3.2", - "express-rate-limit": "^7.1.5" + "express-rate-limit": "^7.1.5", + "socket.io": "^4.6.1" } } diff --git a/backend/src/index.js b/backend/src/index.js index 98a5deb..b6e8ca9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -4,10 +4,12 @@ import dotenv from 'dotenv'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; +import { createServer } from 'http'; 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 { initializeSocket } from './sockets/index.js'; import authRoutes from './routes/auth.js'; import challengeRoutes from './routes/challenges.js'; import predictionRoutes from './routes/predictions.js'; @@ -24,6 +26,10 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); +const httpServer = createServer(app); + +// Initialize Socket.io +initializeSocket(httpServer); // Rate limiting const authLimiter = rateLimit({ @@ -96,8 +102,9 @@ const PORT = config.server.port; // Initialize database and start server initDB() .then(() => { - app.listen(PORT, '0.0.0.0', () => { + httpServer.listen(PORT, '0.0.0.0', () => { console.log(`✅ Server running on port ${PORT}`); + console.log(`🔌 Socket.io ready for real-time updates`); }); }) .catch(err => { diff --git a/backend/src/routes/challenges.js b/backend/src/routes/challenges.js index cb6dd48..e90aa61 100644 --- a/backend/src/routes/challenges.js +++ b/backend/src/routes/challenges.js @@ -2,6 +2,7 @@ import express from 'express'; import { query } from '../db/index.js'; import { authMiddleware } from '../middleware/auth.js'; import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { socketEvents } from '../sockets/index.js'; const router = express.Router(); @@ -147,46 +148,49 @@ router.post('/:id/invite', authMiddleware, asyncHandler(async (req, res) => { [challengeId, userId] ); invitedUsers.push(userId); + + // Emit real-time notification to invited user + socketEvents.challengeInvitation(userId, { + challenge_id: challengeId, + challenge_title: challenge.title, + invited_by: req.user.username + }); + } catch (err) { + // Ignore duplicate key errors + } + } + } + + // Invite by emails + if (emails && Array.isArray(emails)) { + for (const email of emails) { + const users = await query('SELECT id FROM users WHERE email = ?', [email]); + if (users.length > 0) { + try { + await query( + 'INSERT INTO challenge_participants (challenge_id, user_id, status) VALUES (?, ?, "pending")', + [challengeId, users[0].id] + ); + invitedUsers.push(users[0].id); + + // Emit real-time notification to invited user + socketEvents.challengeInvitation(users[0].id, { + challenge_id: challengeId, + challenge_title: challenge.title, + invited_by: req.user.username + }); } catch (err) { // Ignore duplicate key errors } } } - - // Invite by emails - if (emails && Array.isArray(emails)) { - for (const email of emails) { - const users = await query('SELECT id FROM users WHERE email = ?', [email]); - if (users.length > 0) { - try { - await query( - 'INSERT INTO challenge_participants (challenge_id, user_id, status) VALUES (?, ?, "pending")', - [challengeId, users[0].id] - ); - invitedUsers.push(users[0].id); - } catch (err) { - // Ignore duplicate key errors - } - } - } - } - - res.json({ invited: invitedUsers.length }); -})); - -// Accept/reject challenge invitation -router.post('/:id/respond', authMiddleware, asyncHandler(async (req, res) => { - const challengeId = req.params.id; - const { status } = req.body; // 'accepted' or 'rejected' - - if (!['accepted', 'rejected'].includes(status)) { - throw new AppError('Invalid status', 400); } - - await query( - 'UPDATE challenge_participants SET status = ?, responded_at = NOW() WHERE challenge_id = ? AND user_id = ?', - [status, challengeId, req.user.userId] - ); + // Emit real-time event to challenge participants + socketEvents.challengeInvitationResponse(challengeId, { + user_id: req.user.userId, + username: req.user.username, + status + }); res.json({ status }); })); diff --git a/backend/src/routes/friends.js b/backend/src/routes/friends.js index 3205e8e..85139a0 100644 --- a/backend/src/routes/friends.js +++ b/backend/src/routes/friends.js @@ -2,6 +2,7 @@ import express from 'express'; import { query } from '../db/index.js'; import { authMiddleware } from '../middleware/auth.js'; import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { socketEvents } from '../sockets/index.js'; const router = express.Router(); @@ -99,6 +100,12 @@ router.post('/request', authMiddleware, asyncHandler(async (req, res) => { [req.user.userId, user_id] ); + // Emit real-time notification to the user receiving the friend request + socketEvents.friendRequest(user_id, { + from_user_id: req.user.userId, + from_username: req.user.username + }); + res.json({ success: true }); })); @@ -125,6 +132,16 @@ router.post('/respond', authMiddleware, asyncHandler(async (req, res) => { [status, friendship_id] ); + // Get the friendship to notify the requester + const friendship = friendships[0]; + + // Emit real-time notification to the user who sent the request + socketEvents.friendRequestResponse(friendship.user_id, { + friend_id: req.user.userId, + friend_username: req.user.username, + status + }); + res.json({ status }); })); diff --git a/backend/src/routes/predictions.js b/backend/src/routes/predictions.js index c15c6e2..18f4e1b 100644 --- a/backend/src/routes/predictions.js +++ b/backend/src/routes/predictions.js @@ -2,6 +2,7 @@ import express from 'express'; import { query } from '../db/index.js'; import { authMiddleware } from '../middleware/auth.js'; import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { socketEvents } from '../sockets/index.js'; const router = express.Router(); @@ -73,6 +74,9 @@ router.post('/', authMiddleware, asyncHandler(async (req, res) => { created_at: new Date() }; + // Emit real-time event to all users in the challenge + socketEvents.predictionCreated(challenge_id, prediction); + res.json({ prediction }); })); @@ -120,6 +124,19 @@ router.post('/:id/validate', authMiddleware, asyncHandler(async (req, res) => { [status, req.user.userId, predictionId] ); + // Get updated prediction with usernames + const updatedPrediction = await query( + `SELECT p.*, u.username, v.username as validated_by_username + FROM predictions p + INNER JOIN users u ON p.user_id = u.id + LEFT JOIN users v ON p.validated_by = v.id + WHERE p.id = ?`, + [predictionId] + ); + + // Emit real-time event to all users in the challenge + socketEvents.predictionValidated(prediction.challenge_id, updatedPrediction[0]); + res.json({ status }); })); diff --git a/backend/src/sockets/index.js b/backend/src/sockets/index.js new file mode 100644 index 0000000..1eb4d4c --- /dev/null +++ b/backend/src/sockets/index.js @@ -0,0 +1,111 @@ +/** + * Socket.io setup and event handlers + */ +import { Server } from 'socket.io'; +import jwt from 'jsonwebtoken'; + +let io; + +export function initializeSocket(server) { + io = new Server(server, { + cors: { + origin: process.env.CORS_ORIGIN || '*', + credentials: true + } + }); + + // Authentication middleware for socket connections + io.use((socket, next) => { + try { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication error: No token provided')); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + socket.userId = decoded.userId; + socket.username = decoded.username; + next(); + } catch (err) { + next(new Error('Authentication error: Invalid token')); + } + }); + + io.on('connection', (socket) => { + console.log(`✅ Socket connected: ${socket.username} (${socket.userId})`); + + // Join personal room for user-specific notifications + socket.join(`user:${socket.userId}`); + + // Handle joining challenge rooms + socket.on('join:challenge', (challengeId) => { + socket.join(`challenge:${challengeId}`); + console.log(`👥 ${socket.username} joined challenge:${challengeId}`); + }); + + // Handle leaving challenge rooms + socket.on('leave:challenge', (challengeId) => { + socket.leave(`challenge:${challengeId}`); + console.log(`👋 ${socket.username} left challenge:${challengeId}`); + }); + + socket.on('disconnect', () => { + console.log(`❌ Socket disconnected: ${socket.username}`); + }); + }); + + return io; +} + +export function getIO() { + if (!io) { + throw new Error('Socket.io not initialized'); + } + return io; +} + +// Event emitters for different actions +export const socketEvents = { + // Emit to specific user + emitToUser(userId, event, data) { + io.to(`user:${userId}`).emit(event, data); + }, + + // Emit to all users in a challenge + emitToChallenge(challengeId, event, data) { + io.to(`challenge:${challengeId}`).emit(event, data); + }, + + // Prediction events + predictionCreated(challengeId, prediction) { + this.emitToChallenge(challengeId, 'prediction:created', prediction); + }, + + predictionValidated(challengeId, prediction) { + this.emitToChallenge(challengeId, 'prediction:validated', prediction); + }, + + // Challenge events + challengeInvitation(userId, challenge) { + this.emitToUser(userId, 'challenge:invitation', challenge); + }, + + challengeInvitationResponse(challengeId, response) { + this.emitToChallenge(challengeId, 'challenge:invitation_response', response); + }, + + // Friend events + friendRequest(userId, request) { + this.emitToUser(userId, 'friend:request', request); + }, + + friendRequestResponse(userId, response) { + this.emitToUser(userId, 'friend:response', response); + }, + + // Leaderboard updates + leaderboardUpdate(challengeId, leaderboard) { + this.emitToChallenge(challengeId, 'leaderboard:update', leaderboard); + } +}; diff --git a/frontend/package.json b/frontend/package.json index 4e3e246..f080b17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.20.0", - "react-hot-toast": "^2.4.1" + "react-hot-toast": "^2.4.1", + "socket.io-client": "^4.6.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/src/SocketContext.jsx b/frontend/src/SocketContext.jsx new file mode 100644 index 0000000..3190ea8 --- /dev/null +++ b/frontend/src/SocketContext.jsx @@ -0,0 +1,91 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { io } from 'socket.io-client'; +import { useAuth } from './AuthContext'; + +const SocketContext = createContext(); + +export function useSocket() { + const context = useContext(SocketContext); + if (!context) { + throw new Error('useSocket must be used within SocketProvider'); + } + return context; +} + +export function SocketProvider({ children }) { + const [socket, setSocket] = useState(null); + const [connected, setConnected] = useState(false); + const { user, token } = useAuth(); + + useEffect(() => { + // Only connect if user is authenticated + if (!user || !token) { + if (socket) { + socket.disconnect(); + setSocket(null); + setConnected(false); + } + return; + } + + // Determine backend URL + const backendUrl = import.meta.env.VITE_API_URL || + (import.meta.env.DEV ? 'http://localhost:4000' : window.location.origin); + + // Create socket connection with JWT authentication + const newSocket = io(backendUrl, { + auth: { + token + }, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000 + }); + + newSocket.on('connect', () => { + console.log('🔌 Socket connected'); + setConnected(true); + }); + + newSocket.on('disconnect', () => { + console.log('❌ Socket disconnected'); + setConnected(false); + }); + + newSocket.on('connect_error', (error) => { + console.error('Socket connection error:', error.message); + }); + + setSocket(newSocket); + + // Cleanup on unmount + return () => { + newSocket.disconnect(); + }; + }, [user, token]); + + const joinChallenge = (challengeId) => { + if (socket && connected) { + socket.emit('join:challenge', challengeId); + } + }; + + const leaveChallenge = (challengeId) => { + if (socket && connected) { + socket.emit('leave:challenge', challengeId); + } + }; + + const value = { + socket, + connected, + joinChallenge, + leaveChallenge + }; + + return ( + + {children} + + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index ccebeee..ef5ae73 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,6 +3,7 @@ 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 { SocketProvider } from './SocketContext'; import Login from './pages/Login'; import Register from './pages/Register'; import ChallengeList from './pages/ChallengeList'; @@ -55,40 +56,42 @@ function App() { - + -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + error: { + iconTheme: { + primary: '#ef4444', + secondary: '#f1f5f9', + }, + }, + }} + /> +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/frontend/src/pages/ChallengeDetail.jsx b/frontend/src/pages/ChallengeDetail.jsx index 41b490c..9f60253 100644 --- a/frontend/src/pages/ChallengeDetail.jsx +++ b/frontend/src/pages/ChallengeDetail.jsx @@ -2,12 +2,14 @@ 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 { useSocket } from '../SocketContext'; import api from '../api'; import { useClickOutside } from '../hooks/useClickOutside'; export default function ChallengeDetail() { const { id } = useParams(); const { user } = useAuth(); + const { socket, joinChallenge, leaveChallenge } = useSocket(); const [challenge, setChallenge] = useState(null); const [predictions, setPredictions] = useState([]); const [leaderboard, setLeaderboard] = useState([]); @@ -29,6 +31,59 @@ export default function ChallengeDetail() { loadLeaderboard(); }, [id]); + // Join challenge room for real-time updates + useEffect(() => { + if (socket && id) { + joinChallenge(id); + + return () => { + leaveChallenge(id); + }; + } + }, [socket, id]); + + // Listen for real-time prediction events + useEffect(() => { + if (!socket) return; + + const handlePredictionCreated = (prediction) => { + setPredictions(prev => [prediction, ...prev]); + toast.success(`New prediction from ${prediction.username}`); + }; + + const handlePredictionValidated = (prediction) => { + setPredictions(prev => + prev.map(p => p.id === prediction.id ? prediction : p) + ); + loadLeaderboard(); // Refresh leaderboard when points change + + if (prediction.user_id === user.id) { + toast.success( + prediction.status === 'validated' + ? '🎉 Your prediction was validated!' + : '❌ Your prediction was invalidated' + ); + } + }; + + const handleInvitationResponse = (response) => { + if (response.status === 'accepted') { + toast.success(`${response.username} joined the challenge!`); + loadChallenge(); // Refresh participant list + } + }; + + socket.on('prediction:created', handlePredictionCreated); + socket.on('prediction:validated', handlePredictionValidated); + socket.on('challenge:invitation_response', handleInvitationResponse); + + return () => { + socket.off('prediction:created', handlePredictionCreated); + socket.off('prediction:validated', handlePredictionValidated); + socket.off('challenge:invitation_response', handleInvitationResponse); + }; + }, [socket, user]); + const loadChallenge = async () => { try { const data = await api.getChallenge(id); diff --git a/frontend/src/pages/ChallengeList.jsx b/frontend/src/pages/ChallengeList.jsx index 96d882d..1e6dbc2 100644 --- a/frontend/src/pages/ChallengeList.jsx +++ b/frontend/src/pages/ChallengeList.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import toast from 'react-hot-toast'; import api from '../api'; import { useClickOutside } from '../hooks/useClickOutside'; +import { useSocket } from '../SocketContext'; export default function ChallengeList() { const [challenges, setChallenges] = useState([]); @@ -13,6 +14,7 @@ export default function ChallengeList() { const [creating, setCreating] = useState(false); const [searchTimeout, setSearchTimeout] = useState(null); const [respondingTo, setRespondingTo] = useState(null); + const { socket } = useSocket(); const searchRef = useRef(null); useClickOutside(searchRef, () => setShowResults([])); @@ -21,6 +23,22 @@ export default function ChallengeList() { loadChallenges(); }, []); + // Listen for real-time challenge invitations + useEffect(() => { + if (!socket) return; + + const handleChallengeInvitation = (invitation) => { + toast.success(`📬 ${invitation.invited_by} invited you to "${invitation.challenge_title}"`); + loadChallenges(); // Refresh to show new invitation + }; + + socket.on('challenge:invitation', handleChallengeInvitation); + + return () => { + socket.off('challenge:invitation', handleChallengeInvitation); + }; + }, [socket]); + const loadChallenges = async () => { try { const data = await api.getChallenges(); diff --git a/frontend/src/pages/Friends.jsx b/frontend/src/pages/Friends.jsx index ce16244..cc9dbb8 100644 --- a/frontend/src/pages/Friends.jsx +++ b/frontend/src/pages/Friends.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import toast from 'react-hot-toast'; import api from '../api'; import { useClickOutside } from '../hooks/useClickOutside'; +import { useSocket } from '../SocketContext'; export default function Friends() { const [friends, setFriends] = useState([]); @@ -13,6 +14,7 @@ export default function Friends() { const [searchTimeout, setSearchTimeout] = useState(null); const [responding, setResponding] = useState(null); const [sending, setSending] = useState(null); + const { socket } = useSocket(); const searchRef = useRef(null); useClickOutside(searchRef, () => setSearchResults([])); @@ -21,6 +23,33 @@ export default function Friends() { loadData(); }, []); + // Listen for real-time friend request events + useEffect(() => { + if (!socket) return; + + const handleFriendRequest = (request) => { + toast.success(`👋 ${request.from_username} sent you a friend request`); + loadData(); // Refresh to show new request + }; + + const handleFriendResponse = (response) => { + if (response.status === 'accepted') { + toast.success(`🎉 ${response.friend_username} accepted your friend request!`); + loadData(); // Refresh friends list + } else { + toast(`${response.friend_username} declined your friend request`); + } + }; + + socket.on('friend:request', handleFriendRequest); + socket.on('friend:response', handleFriendResponse); + + return () => { + socket.off('friend:request', handleFriendRequest); + socket.off('friend:response', handleFriendResponse); + }; + }, [socket]); + const loadData = async () => { try { const [friendsData, requestsData] = await Promise.all([