From 71138949495b5468518b15f405324ba2b001cb8e Mon Sep 17 00:00:00 2001 From: Mansi2007275 Date: Sun, 23 Aug 2026 19:49:25 +0530 Subject: [PATCH 1/2] feat: add friend challenge with invite link and rematch --- backend/cmd/server/main.go | 2 + backend/routes/rooms.go | 258 ++++++++++++++++++--- frontend/src/App.tsx | 13 +- frontend/src/Pages/OnlineDebateRoom.tsx | 88 ++++++- frontend/src/Pages/Profile.tsx | 14 ++ frontend/src/components/ChallengeModal.tsx | 161 +++++++++++++ frontend/src/components/JudgementPopup.tsx | 12 + frontend/src/context/authContext.tsx | 15 +- 8 files changed, 520 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/ChallengeModal.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a4a346e7..3758df43 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -138,7 +138,9 @@ func setupRouter(cfg *config.Config) *gin.Engine { // Add Room routes. auth.GET("/rooms", routes.GetRoomsHandler) auth.POST("/rooms", routes.CreateRoomHandler) + auth.POST("/rooms/challenge", routes.CreateChallengeHandler) auth.POST("/rooms/:id/join", routes.JoinRoomHandler) + auth.POST("/rooms/:id/rematch", routes.RematchHandler) auth.GET("/rooms/:id/participants", routes.GetRoomParticipantsHandler) // Chat functionality is now handled by the main WebSocket handler diff --git a/backend/routes/rooms.go b/backend/routes/rooms.go index b4df0c75..9af638d3 100644 --- a/backend/routes/rooms.go +++ b/backend/routes/rooms.go @@ -2,10 +2,13 @@ package routes import ( "context" + "crypto/rand" + "encoding/hex" "math" - "math/rand" + mathrand "math/rand" "net/http" "strconv" + "strings" "time" "arguehub/db" @@ -19,10 +22,13 @@ import ( // Room represents a debate room. type Room struct { - ID string `json:"id" bson:"_id"` - Type string `json:"type" bson:"type"` - OwnerID string `json:"ownerId" bson:"ownerId"` - Participants []Participant `json:"participants" bson:"participants"` + ID string `json:"id" bson:"_id"` + Type string `json:"type" bson:"type"` + OwnerID string `json:"ownerId" bson:"ownerId"` + Participants []Participant `json:"participants" bson:"participants"` + InviteToken string `json:"inviteToken,omitempty" bson:"inviteToken,omitempty"` + Topic string `json:"topic,omitempty" bson:"topic,omitempty"` + InvitedUsername string `json:"invitedUsername,omitempty" bson:"invitedUsername,omitempty"` } // Participant represents a user in a room. @@ -36,8 +42,41 @@ type Participant struct { // generateRoomID creates a random six-digit room ID as a string. func generateRoomID() string { - rand.Seed(time.Now().UnixNano()) - return strconv.Itoa(rand.Intn(900000) + 100000) + mathrand.Seed(time.Now().UnixNano()) + return strconv.Itoa(mathrand.Intn(900000) + 100000) +} + +func generateInviteToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + return hex.EncodeToString(b) +} + +type roomUser struct { + ID primitive.ObjectID `bson:"_id"` + Email string `bson:"email"` + DisplayName string `bson:"displayName"` + Rating float64 `bson:"rating"` + AvatarURL string `bson:"avatarUrl"` +} + +func fetchUserByEmail(ctx context.Context, email string) (roomUser, error) { + userCollection := db.MongoDatabase.Collection("users") + var user roomUser + err := userCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user) + return user, err +} + +func userToParticipant(user roomUser) Participant { + return Participant{ + ID: user.ID.Hex(), + Username: user.DisplayName, + Elo: int(math.Round(user.Rating)), + AvatarURL: user.AvatarURL, + Email: user.Email, + } } // CreateRoomHandler handles POST /rooms and creates a new debate room. @@ -61,7 +100,7 @@ func CreateRoomHandler(c *gin.Context) { } // Query user document using email - userCollection := db.MongoClient.Database("DebateAI").Collection("users") + userCollection := db.MongoDatabase.Collection("users") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -96,7 +135,7 @@ func CreateRoomHandler(c *gin.Context) { Participants: []Participant{creatorParticipant}, } - roomCollection := db.MongoClient.Database("DebateAI").Collection("rooms") + roomCollection := db.MongoDatabase.Collection("rooms") _, err = roomCollection.InsertOne(ctx, newRoom) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create room"}) @@ -109,7 +148,7 @@ func CreateRoomHandler(c *gin.Context) { // GetRoomsHandler handles GET /rooms and returns all rooms. func GetRoomsHandler(c *gin.Context) { - collection := db.MongoClient.Database("DebateAI").Collection("rooms") + collection := db.MongoDatabase.Collection("rooms") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -132,49 +171,66 @@ func GetRoomsHandler(c *gin.Context) { func JoinRoomHandler(c *gin.Context) { roomId := c.Param("id") - // Get user email from middleware-set context + type JoinRoomInput struct { + InviteToken string `json:"inviteToken"` + } + var input JoinRoomInput + _ = c.ShouldBindJSON(&input) + email, exists := c.Get("email") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: user email not found"}) return } - // Query user document using email - userCollection := db.MongoClient.Database("DebateAI").Collection("users") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - emailStr, ok := email.(string) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid email format"}) return } - var user struct { - ID primitive.ObjectID `bson:"_id"` - Email string `bson:"email"` - DisplayName string `bson:"displayName"` - Rating float64 `bson:"rating"` - AvatarURL string `bson:"avatarUrl"` - } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() - err := userCollection.FindOne(ctx, bson.M{"email": emailStr}).Decode(&user) + user, err := fetchUserByEmail(ctx, emailStr) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } - // Create participant - participant := Participant{ - ID: user.ID.Hex(), - Username: user.DisplayName, - Elo: int(math.Round(user.Rating)), - AvatarURL: user.AvatarURL, - Email: user.Email, + participant := userToParticipant(user) + + roomCollection := db.MongoDatabase.Collection("rooms") + var room Room + if err := roomCollection.FindOne(ctx, bson.M{"_id": roomId}).Decode(&room); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Room not found"}) + return + } + + alreadyIn := false + for _, p := range room.Participants { + if p.ID == participant.ID { + alreadyIn = true + break + } + } + + if room.InviteToken != "" && !alreadyIn { + if input.InviteToken == "" || input.InviteToken != room.InviteToken { + c.JSON(http.StatusForbidden, gin.H{"error": "Invalid invite token"}) + return + } + if len(room.Participants) >= 2 { + c.JSON(http.StatusConflict, gin.H{"error": "Room is full"}) + return + } + } + + if alreadyIn { + c.JSON(http.StatusOK, room) + return } - // Use atomic operation to join room - roomCollection := db.MongoClient.Database("DebateAI").Collection("rooms") filter := bson.M{"_id": roomId} update := bson.M{ "$addToSet": bson.M{"participants": participant}, @@ -187,7 +243,6 @@ func JoinRoomHandler(c *gin.Context) { return } - // Remove user from matchmaking pool if they were in it matchmakingService := services.GetMatchmakingService() matchmakingService.RemoveFromPool(user.ID.Hex()) @@ -206,8 +261,8 @@ func GetRoomParticipantsHandler(c *gin.Context) { } // Query room document - roomCollection := db.MongoClient.Database("DebateAI").Collection("rooms") - userCollection := db.MongoClient.Database("DebateAI").Collection("users") + roomCollection := db.MongoDatabase.Collection("rooms") + userCollection := db.MongoDatabase.Collection("users") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -326,3 +381,134 @@ func GetRoomParticipantsHandler(c *gin.Context) { "participants": participantsWithDetails, }) } + +// CreateChallengeHandler handles POST /rooms/challenge. +func CreateChallengeHandler(c *gin.Context) { + type CreateChallengeInput struct { + OpponentUsername string `json:"opponentUsername"` + Topic string `json:"topic"` + } + + var input CreateChallengeInput + if err := c.ShouldBindJSON(&input); err != nil || strings.TrimSpace(input.Topic) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Topic is required"}) + return + } + + email, exists := c.Get("email") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: user email not found"}) + return + } + + emailStr, ok := email.(string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid email format"}) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := fetchUserByEmail(ctx, emailStr) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + opponentUsername := strings.TrimSpace(input.OpponentUsername) + if opponentUsername != "" { + userCollection := db.MongoDatabase.Collection("users") + var opponent roomUser + _ = userCollection.FindOne(ctx, bson.M{"displayName": opponentUsername}).Decode(&opponent) + } + + creatorParticipant := userToParticipant(user) + inviteToken := generateInviteToken() + roomID := generateRoomID() + + newRoom := Room{ + ID: roomID, + Type: "invite", + OwnerID: creatorParticipant.ID, + Participants: []Participant{creatorParticipant}, + InviteToken: inviteToken, + Topic: strings.TrimSpace(input.Topic), + InvitedUsername: opponentUsername, + } + + roomCollection := db.MongoDatabase.Collection("rooms") + if _, err := roomCollection.InsertOne(ctx, newRoom); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge room"}) + return + } + + c.JSON(http.StatusOK, newRoom) +} + +// RematchHandler handles POST /rooms/:id/rematch. +func RematchHandler(c *gin.Context) { + roomId := c.Param("id") + + email, exists := c.Get("email") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: user email not found"}) + return + } + + emailStr, ok := email.(string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid email format"}) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := fetchUserByEmail(ctx, emailStr) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + roomCollection := db.MongoDatabase.Collection("rooms") + var room Room + if err := roomCollection.FindOne(ctx, bson.M{"_id": roomId}).Decode(&room); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Room not found"}) + return + } + + if room.InviteToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Rematch is only available for challenge rooms"}) + return + } + + wasParticipant := false + for _, p := range room.Participants { + if p.ID == user.ID.Hex() { + wasParticipant = true + break + } + } + if !wasParticipant { + c.JSON(http.StatusForbidden, gin.H{"error": "You were not in this challenge"}) + return + } + + creatorParticipant := userToParticipant(user) + newRoom := Room{ + ID: generateRoomID(), + Type: "invite", + OwnerID: creatorParticipant.ID, + Participants: []Participant{creatorParticipant}, + InviteToken: generateInviteToken(), + Topic: room.Topic, + } + + if _, err := roomCollection.InsertOne(ctx, newRoom); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rematch room"}) + return + } + + c.JSON(http.StatusOK, newRoom) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe5cf269..7fa00b2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom'; import { AuthProvider, AuthContext } from './context/authContext'; import { ThemeProvider } from './context/theme-provider'; // Pages @@ -29,11 +29,11 @@ import CommunityFeed from './Pages/CommunityFeed'; import AdminSignup from './Pages/Admin/AdminSignup'; import AdminDashboard from './Pages/Admin/AdminDashboard'; import ViewDebate from './Pages/ViewDebate'; -import SupportOpenSource from './Pages/SupportOpenSource'; // Protects routes based on authentication status function ProtectedRoute() { const authContext = useContext(AuthContext); + const location = useLocation(); if (!authContext) { throw new Error('ProtectedRoute must be used within an AuthProvider'); } @@ -41,7 +41,14 @@ function ProtectedRoute() { if (isLoading) { return
Loading...
; } - return isAuthenticated ? : ; + if (!isAuthenticated) { + sessionStorage.setItem( + 'returnUrl', + location.pathname + location.search + ); + return ; + } + return ; } // Defines application routes diff --git a/frontend/src/Pages/OnlineDebateRoom.tsx b/frontend/src/Pages/OnlineDebateRoom.tsx index 613c5df6..5b751182 100644 --- a/frontend/src/Pages/OnlineDebateRoom.tsx +++ b/frontend/src/Pages/OnlineDebateRoom.tsx @@ -5,7 +5,7 @@ import React, { useRef, useState, } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams, useNavigate } from "react-router-dom"; import { Button } from "../components/ui/button"; import JudgmentPopup from "@/components/JudgementPopup"; @@ -146,6 +146,9 @@ const WS_BASE_URL = BASE_URL.replace( const OnlineDebateRoom = (): JSX.Element => { const { roomId } = useParams<{ roomId: string }>(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const inviteToken = searchParams.get("invite"); const { user: currentUser } = useUser(); const currentUserId = currentUser?.id ?? null; useDebateWS(roomId ?? null); @@ -469,6 +472,8 @@ const OnlineDebateRoom = (): JSX.Element => { const [ratingSummary, setRatingSummary] = useState( null ); + const [isChallengeRoom, setIsChallengeRoom] = useState(false); + const [joinError, setJoinError] = useState(null); // Ordered list of debate phases const phaseOrder = useMemo( @@ -918,6 +923,69 @@ const OnlineDebateRoom = (): JSX.Element => { return false; }, [roomId, currentUser, setRoomOwnerId]); + const handleRematch = useCallback(async () => { + if (!roomId) return; + + try { + const token = getAuthToken(); + const response = await fetch(`${BASE_URL}/rooms/${roomId}/rematch`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const data = await response.json(); + if (response.ok) { + navigate(`/debate-room/${data.id}?invite=${data.inviteToken}`); + } + } catch (error) { + console.error("Failed to create rematch:", error); + } + }, [roomId, navigate]); + + useEffect(() => { + const joinChallengeRoom = async () => { + if (!roomId || !currentUser) return; + + try { + const token = getAuthToken(); + const body: { inviteToken?: string } = {}; + if (inviteToken) { + body.inviteToken = inviteToken; + } + + const response = await fetch(`${BASE_URL}/rooms/${roomId}/join`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (response.ok) { + const room = await response.json(); + if (room.inviteToken) { + setIsChallengeRoom(true); + } + if (room.topic) { + setTopic(room.topic); + } + } else if (inviteToken) { + const data = await response.json(); + setJoinError(data.error || "Failed to join challenge room"); + } + } catch { + if (inviteToken) { + setJoinError("Failed to join challenge room"); + } + } + }; + + joinChallengeRoom(); + }, [roomId, currentUser, inviteToken]); + // Function to fetch room participants const fetchRoomParticipants = useCallback( async (retryCount = 0) => { @@ -2089,6 +2157,22 @@ const OnlineDebateRoom = (): JSX.Element => { ); } + if (joinError) { + return ( +
+
+

{joinError}

+ +
+
+ ); + } + // Render UI return (
@@ -2368,6 +2452,8 @@ const OnlineDebateRoom = (): JSX.Element => { } opponentAvatarUrl={opponentUser?.avatarUrl || null} ratingSummary={ratingSummary} + showRematch={isChallengeRoom} + onRematch={handleRematch} onClose={() => setShowJudgment(false)} /> )} diff --git a/frontend/src/Pages/Profile.tsx b/frontend/src/Pages/Profile.tsx index 9aeaf177..64a3f15f 100644 --- a/frontend/src/Pages/Profile.tsx +++ b/frontend/src/Pages/Profile.tsx @@ -50,6 +50,7 @@ import { Image as ImageIcon, ChevronRight, Flame, + Swords, } from "lucide-react"; import { FaTrophy, FaMedal, FaAward } from "react-icons/fa"; import { format, isSameDay, subDays } from "date-fns"; @@ -75,6 +76,7 @@ import { getProfile, updateProfile } from "@/services/profileService"; import { getAuthToken } from "@/utils/auth"; import { DateRange } from "react-day-picker"; import AvatarModal from "../components/AvatarModal"; +import ChallengeModal from "../components/ChallengeModal"; import SavedTranscripts from "../components/SavedTranscripts"; import ProfileHover from "../components/ProfileHover"; import { useUser } from "../hooks/useUser"; @@ -146,6 +148,7 @@ const Profile: React.FC = () => { "7days" | "30days" | "all" | "custom" >("all"); const [isAvatarModalOpen, setIsAvatarModalOpen] = useState(false); + const [showChallengeModal, setShowChallengeModal] = useState(false); const [debateStatsLoading, setDebateStatsLoading] = useState(true); const [recentDebates, setRecentDebates] = useState< Array<{ @@ -860,6 +863,14 @@ const Profile: React.FC = () => { Streak: {profile.currentStreak} days

)} +

@@ -1490,6 +1501,9 @@ const Profile: React.FC = () => { )} + {showChallengeModal && ( + setShowChallengeModal(false)} /> + )} ); }; diff --git a/frontend/src/components/ChallengeModal.tsx b/frontend/src/components/ChallengeModal.tsx new file mode 100644 index 00000000..cd4af87d --- /dev/null +++ b/frontend/src/components/ChallengeModal.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { X, Copy, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { getAuthToken } from '@/utils/auth'; + +interface ChallengeModalProps { + onClose: () => void; +} + +type ChallengeRoom = { + id: string; + inviteToken: string; + topic: string; +}; + +const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:1313'; + +const ChallengeModal: React.FC = ({ onClose }) => { + const navigate = useNavigate(); + const [opponentUsername, setOpponentUsername] = useState(''); + const [topic, setTopic] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [challenge, setChallenge] = useState(null); + const [copied, setCopied] = useState(false); + + const inviteLink = challenge + ? `${window.location.origin}/debate-room/${challenge.id}?invite=${challenge.inviteToken}` + : ''; + + const handleCreate = async () => { + if (!topic.trim()) { + setError('Please enter a debate topic'); + return; + } + + setLoading(true); + setError(''); + + try { + const token = getAuthToken(); + const response = await fetch(`${BASE_URL}/rooms/challenge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + opponentUsername: opponentUsername.trim(), + topic: topic.trim(), + }), + }); + + const data = await response.json(); + if (!response.ok) { + setError(data.error || 'Failed to create challenge'); + return; + } + + setChallenge({ + id: data.id, + inviteToken: data.inviteToken, + topic: data.topic, + }); + } catch { + setError('Failed to create challenge'); + } finally { + setLoading(false); + } + }; + + const handleCopy = async () => { + if (!inviteLink) return; + await navigator.clipboard.writeText(inviteLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleEnterRoom = () => { + if (!challenge) return; + navigate(`/debate-room/${challenge.id}?invite=${challenge.inviteToken}`); + onClose(); + }; + + return ( +

+
+ + + {!challenge ? ( + <> +

Challenge a Friend

+
+
+ + setOpponentUsername(e.target.value)} + placeholder='Their display name' + className='mt-1' + /> +
+
+ + setTopic(e.target.value)} + placeholder='e.g. Should AI replace teachers?' + className='mt-1' + /> +
+ {error && ( +

{error}

+ )} + +
+ + ) : ( + <> +

Challenge Created

+

+ Share this link with your opponent. Topic: {challenge.topic} +

+
+ + +
+
+ + +
+ + )} +
+
+ ); +}; + +export default ChallengeModal; diff --git a/frontend/src/components/JudgementPopup.tsx b/frontend/src/components/JudgementPopup.tsx index 1640dacb..08423270 100644 --- a/frontend/src/components/JudgementPopup.tsx +++ b/frontend/src/components/JudgementPopup.tsx @@ -81,6 +81,8 @@ type JudgmentPopupProps = { opponentDisplayName?: string | null; opponentAvatarUrl?: string | null; ratingSummary?: RatingSummary | null; + showRematch?: boolean; + onRematch?: () => void; onClose: () => void; }; @@ -121,6 +123,8 @@ const JudgmentPopup: React.FC = ({ opponentDisplayName, opponentAvatarUrl, ratingSummary, + showRematch, + onRematch, onClose, }) => { const navigate = useNavigate(); @@ -731,6 +735,14 @@ const player2RatingSummary = {/* Buttons */}
+ {showRematch && onRematch && ( + + )}
@@ -143,6 +165,9 @@ const ChallengeModal: React.FC = ({ onClose }) => { {copied ? : }
+ {copyError && ( +

{copyError}

+ )}