Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.env
config.prod.yml
101 changes: 97 additions & 4 deletions backend/controllers/debatevsbot_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t
return
}

log.Printf("Successfully updated score. New score: %d (was %d, added %d)",
log.Printf("Successfully updated score. New score: %d (was %d, added %d)",
updatedUser.Score, user.Score, pointsToAdd)

// Save score update record
Expand Down Expand Up @@ -418,11 +418,11 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t
if resultStatus == "win" && !hasBadge["FirstWin"] {
badgeUpdate := bson.M{"$addToSet": bson.M{"badges": "FirstWin"}}
userCollection.UpdateOne(ctx, bson.M{"_id": userID}, badgeUpdate)

// Update the updatedUser object to include the new badge
updatedUser.Badges = append(updatedUser.Badges, "FirstWin")
hasBadge["FirstWin"] = true

// Save badge record
badgeCollection := db.MongoDatabase.Collection("user_badges")
userBadge := models.UserBadge{
Expand Down Expand Up @@ -460,7 +460,7 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t
Timestamp: time.Now(),
})

log.Printf("Updated gamification for user %s: +%d points (new score: %d), result: %s",
log.Printf("Updated gamification for user %s: +%d points (new score: %d), result: %s",
userID.Hex(), pointsToAdd, updatedUser.Score, resultStatus)
}

Expand Down Expand Up @@ -514,3 +514,96 @@ func checkAndAwardAutomaticBadges(ctx context.Context, userID primitive.ObjectID
// Check for Debater10 badge (10 debates completed)
// Note: This would require tracking debate count, which might need to be added
}

type ConcedeRequest struct {
DebateId string `json:"debateId" binding:"required"`
History []models.Message `json:"history"`
}

func ConcedeDebate(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.JSON(401, gin.H{"error": "Authorization token required"})
return
}

token = strings.TrimPrefix(token, "Bearer ")
valid, email, err := utils.ValidateTokenAndFetchEmail("./config/config.prod.yml", token, c)
if err != nil || !valid {
c.JSON(401, gin.H{"error": "Invalid or expired token"})
return
}

var req ConcedeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request payload: " + err.Error()})
return
}

objID, err := primitive.ObjectIDFromHex(req.DebateId)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid debate ID"})
return
}

// Fetch the debate to get details
var debate models.DebateVsBot
err = db.DebateVsBotCollection.FindOne(context.Background(), bson.M{"_id": objID}).Decode(&debate)
if err != nil {
c.JSON(404, gin.H{"error": "Debate not found"})
return
}

// Update debate outcome
filter := bson.M{"_id": objID}
update := bson.M{"$set": bson.M{"outcome": "User conceded"}}

_, err = db.DebateVsBotCollection.UpdateOne(context.Background(), filter, update)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to update debate: " + err.Error()})
return
}

// Get user ID from email
userCollection := db.GetCollection("users")
var user models.User
err = userCollection.FindOne(context.Background(), bson.M{"email": email}).Decode(&user)
if err != nil {
// Log error but don't fail the request as the main action succeeded
log.Printf("Error finding user for concede updates: %v", err)
c.JSON(200, gin.H{"message": "Debate conceded successfully"})
return
}

// Save transcript to history
// We treat concession as a loss
// Use history from request if available, otherwise fall back to debate record history
historyToSave := debate.History
if len(req.History) > 0 {
historyToSave = req.History
}

_ = services.SaveDebateTranscript(
user.ID,
email,
"user_vs_bot",
debate.Topic,
debate.BotName,
"loss",
historyToSave,
nil,
)

// Update gamification (score, badges, streaks)
// Call synchronously but with recover to prevent panics
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic in updateGamificationAfterBotDebate (concede): %v", r)
}
}()
updateGamificationAfterBotDebate(user.ID, "loss", debate.Topic)
}()

c.JSON(200, gin.H{"message": "Debate conceded successfully"})
}
Comment on lines +523 to +609
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add context timeouts and reconsider early return behavior.

Several concerns with the ConcedeDebate handler:

  1. Missing context timeouts (Lines 551, 561, 570): All database operations use context.Background() without timeouts, which could cause requests to hang indefinitely if the database is slow or unavailable.

  2. Inconsistent success response (Lines 571-576): When user lookup fails, the function returns HTTP 200 with "Debate conceded successfully", but transcript saving and gamification updates are skipped. This creates an inconsistent state where the debate outcome is updated but user history is not. Consider returning an error status or at least a different message indicating partial success.

  3. Silent failures in gamification (Lines 599-606): If updateGamificationAfterBotDebate panics or errors, it's logged but the user receives a success response, potentially misleading them about their points/badges.

🔎 Suggested improvements
 func ConcedeDebate(c *gin.Context) {
 	token := c.GetHeader("Authorization")
 	if token == "" {
 		c.JSON(401, gin.H{"error": "Authorization token required"})
 		return
 	}
 
 	token = strings.TrimPrefix(token, "Bearer ")
 	valid, email, err := utils.ValidateTokenAndFetchEmail("./config/config.prod.yml", token, c)
 	if err != nil || !valid {
 		c.JSON(401, gin.H{"error": "Invalid or expired token"})
 		return
 	}
 
 	var req ConcedeRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		c.JSON(400, gin.H{"error": "Invalid request payload: " + err.Error()})
 		return
 	}
 
 	objID, err := primitive.ObjectIDFromHex(req.DebateId)
 	if err != nil {
 		c.JSON(400, gin.H{"error": "Invalid debate ID"})
 		return
 	}
 
 	// Fetch the debate to get details
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	
 	var debate models.DebateVsBot
-	err = db.DebateVsBotCollection.FindOne(context.Background(), bson.M{"_id": objID}).Decode(&debate)
+	err = db.DebateVsBotCollection.FindOne(ctx, bson.M{"_id": objID}).Decode(&debate)
 	if err != nil {
 		c.JSON(404, gin.H{"error": "Debate not found"})
 		return
 	}
 
 	// Update debate outcome
 	filter := bson.M{"_id": objID}
 	update := bson.M{"$set": bson.M{"outcome": "User conceded"}}
 
-	_, err = db.DebateVsBotCollection.UpdateOne(context.Background(), filter, update)
+	_, err = db.DebateVsBotCollection.UpdateOne(ctx, filter, update)
 	if err != nil {
 		c.JSON(500, gin.H{"error": "Failed to update debate: " + err.Error()})
 		return
 	}
 
 	// Get user ID from email
 	userCollection := db.GetCollection("users")
 	var user models.User
-	err = userCollection.FindOne(context.Background(), bson.M{"email": email}).Decode(&user)
+	err = userCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
 	if err != nil {
-		// Log error but don't fail the request as the main action succeeded
 		log.Printf("Error finding user for concede updates: %v", err)
-		c.JSON(200, gin.H{"message": "Debate conceded successfully"})
+		// Return error since we couldn't complete the full concession flow
+		c.JSON(500, gin.H{
+			"error": "Debate outcome updated but failed to save transcript",
+			"message": "Please contact support if your history is not updated",
+		})
 		return
 	}
 
 	// Save transcript to history
 	// We treat concession as a loss
 	// Use history from request if available, otherwise fall back to debate record history
 	historyToSave := debate.History
 	if len(req.History) > 0 {
 		historyToSave = req.History
 	}
 
 	_ = services.SaveDebateTranscript(
 		user.ID,
 		email,
 		"user_vs_bot",
 		debate.Topic,
 		debate.BotName,
 		"loss",
 		historyToSave,
 		nil,
 	)
 
 	// Update gamification (score, badges, streaks)
 	// Call synchronously but with recover to prevent panics
 	func() {
 		defer func() {
 			if r := recover(); r != nil {
 				log.Printf("Panic in updateGamificationAfterBotDebate (concede): %v", r)
 			}
 		}()
 		updateGamificationAfterBotDebate(user.ID, "loss", debate.Topic)
 	}()
 
 	c.JSON(200, gin.H{"message": "Debate conceded successfully"})
 }
🤖 Prompt for AI Agents
In backend/controllers/debatevsbot_controller.go (around lines 551–606) the
handler uses context.Background() for DB calls (FindOne/UpdateOne/FindOne on
users), returns 200 when user lookup fails (skipping transcript/gamification),
and swallows gamification failures; fix by using context.WithTimeout (e.g., 5s)
for each DB operation and pass that ctx to FindOne/UpdateOne/FindOne calls,
change the user lookup error path to return a 500 (or a clear partial-success
response) so caller knows the user-related work failed instead of silently
succeeding, ensure the transcript is saved only after a successful user fetch
(or save with best-effort and clearly indicate partial success), and run
updateGamificationAfterBotDebate in a separate goroutine with defer-recover and
proper logging but do not treat gamification panics as full success—capture
errors (via channel or logs) and if gamification fails return or surface a
partial-success response to the client.

1 change: 1 addition & 0 deletions backend/routes/debatevsbot.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ func SetupDebateVsBotRoutes(router *gin.RouterGroup) {
vsbot.POST("/create", controllers.CreateDebate)
vsbot.POST("/debate", controllers.SendDebateMessage)
vsbot.POST("/judge", controllers.JudgeDebate)
vsbot.POST("/concede", controllers.ConcedeDebate)
}
}
44 changes: 44 additions & 0 deletions backend/websocket/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import (
"time"

"arguehub/db"
"arguehub/services"
"arguehub/utils"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
Expand Down Expand Up @@ -470,6 +472,8 @@ func WebsocketHandler(c *gin.Context) {
handleMuteRequest(room, conn, message, client, roomID)
case "unmute":
handleUnmuteRequest(room, conn, message, client, roomID)
case "concede":
handleConcede(room, conn, message, client, roomID)
default:
if message.Type == "requestOffer" && client.IsSpectator {
var req map[string]interface{}
Expand Down Expand Up @@ -750,3 +754,43 @@ func getUserDetails(email string) (string, string, string, int, error) {
rating := int(math.Round(user.Rating))
return user.ID.Hex(), user.DisplayName, user.AvatarURL, rating, nil
}

// handleConcede handles concede requests
func handleConcede(room *Room, conn *websocket.Conn, message Message, client *Client, roomID string) {
// Broadcast concede message to all clients (including spectators)
broadcastMessage := Message{
Type: "concede",
Room: roomID,
Username: client.Username,
UserID: client.UserID,
Content: "User conceded the debate",
}

// Send to all clients
for _, r := range snapshotRecipients(room, nil) {
r.SafeWriteJSON(broadcastMessage)
}

// Find opponent
var opponent *Client
room.Mutex.Lock()
for _, c := range room.Clients {
if !c.IsSpectator && c.UserID != client.UserID {
opponent = c
break
}
}
room.Mutex.Unlock()

if opponent != nil {
// Update ratings
// User lost (0.0), Opponent won (1.0)
userID, _ := primitive.ObjectIDFromHex(client.UserID)
opponentID, _ := primitive.ObjectIDFromHex(opponent.UserID)

_, _, err := services.UpdateRatings(userID, opponentID, 0.0, time.Now())
if err != nil {
log.Printf("Error updating ratings after concede: %v", err)
}
}
}
Comment on lines +758 to +796
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add validation and improve error handling.

Several issues with the handleConcede function:

  1. Missing ObjectID validation (Lines 788-789): If client.UserID or opponent.UserID are not valid hex strings, ObjectIDFromHex will fail silently (errors ignored with _), and the rating update on line 791 will proceed with zero-value ObjectIDs.

  2. Rating update errors not reported (Lines 792-794): When UpdateRatings fails, only a log is written. Clients (including the conceding user) are not notified that ratings weren't updated.

  3. Broadcast logic (Line 770): Using snapshotRecipients(room, nil) sends the concede message back to the sender. While not technically wrong, it's redundant since the sender already knows they conceded.

🔎 Suggested improvements
 func handleConcede(room *Room, conn *websocket.Conn, message Message, client *Client, roomID string) {
 	// Broadcast concede message to all clients (including spectators)
 	broadcastMessage := Message{
 		Type:     "concede",
 		Room:     roomID,
 		Username: client.Username,
 		UserID:   client.UserID,
 		Content:  "User conceded the debate",
 	}
 
 	// Send to all clients
-	for _, r := range snapshotRecipients(room, nil) {
+	for _, r := range snapshotRecipients(room, conn) {
 		r.SafeWriteJSON(broadcastMessage)
 	}
 
 	// Find opponent
 	var opponent *Client
 	room.Mutex.Lock()
 	for _, c := range room.Clients {
 		if !c.IsSpectator && c.UserID != client.UserID {
 			opponent = c
 			break
 		}
 	}
 	room.Mutex.Unlock()
 
 	if opponent != nil {
 		// Update ratings
 		// User lost (0.0), Opponent won (1.0)
-		userID, _ := primitive.ObjectIDFromHex(client.UserID)
-		opponentID, _ := primitive.ObjectIDFromHex(opponent.UserID)
+		userID, err := primitive.ObjectIDFromHex(client.UserID)
+		if err != nil {
+			log.Printf("Error parsing conceding user ID: %v", err)
+			return
+		}
+		opponentID, err := primitive.ObjectIDFromHex(opponent.UserID)
+		if err != nil {
+			log.Printf("Error parsing opponent user ID: %v", err)
+			return
+		}
 
 		_, _, err := services.UpdateRatings(userID, opponentID, 0.0, time.Now())
 		if err != nil {
 			log.Printf("Error updating ratings after concede: %v", err)
+			// Notify clients of the rating update failure
+			errorMsg := Message{
+				Type:    "error",
+				Content: "Rating update failed",
+			}
+			client.SafeWriteJSON(errorMsg)
+			opponent.SafeWriteJSON(errorMsg)
 		}
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// handleConcede handles concede requests
func handleConcede(room *Room, conn *websocket.Conn, message Message, client *Client, roomID string) {
// Broadcast concede message to all clients (including spectators)
broadcastMessage := Message{
Type: "concede",
Room: roomID,
Username: client.Username,
UserID: client.UserID,
Content: "User conceded the debate",
}
// Send to all clients
for _, r := range snapshotRecipients(room, nil) {
r.SafeWriteJSON(broadcastMessage)
}
// Find opponent
var opponent *Client
room.Mutex.Lock()
for _, c := range room.Clients {
if !c.IsSpectator && c.UserID != client.UserID {
opponent = c
break
}
}
room.Mutex.Unlock()
if opponent != nil {
// Update ratings
// User lost (0.0), Opponent won (1.0)
userID, _ := primitive.ObjectIDFromHex(client.UserID)
opponentID, _ := primitive.ObjectIDFromHex(opponent.UserID)
_, _, err := services.UpdateRatings(userID, opponentID, 0.0, time.Now())
if err != nil {
log.Printf("Error updating ratings after concede: %v", err)
}
}
}
// handleConcede handles concede requests
func handleConcede(room *Room, conn *websocket.Conn, message Message, client *Client, roomID string) {
// Broadcast concede message to all clients (including spectators)
broadcastMessage := Message{
Type: "concede",
Room: roomID,
Username: client.Username,
UserID: client.UserID,
Content: "User conceded the debate",
}
// Send to all clients
for _, r := range snapshotRecipients(room, conn) {
r.SafeWriteJSON(broadcastMessage)
}
// Find opponent
var opponent *Client
room.Mutex.Lock()
for _, c := range room.Clients {
if !c.IsSpectator && c.UserID != client.UserID {
opponent = c
break
}
}
room.Mutex.Unlock()
if opponent != nil {
// Update ratings
// User lost (0.0), Opponent won (1.0)
userID, err := primitive.ObjectIDFromHex(client.UserID)
if err != nil {
log.Printf("Error parsing conceding user ID: %v", err)
return
}
opponentID, err := primitive.ObjectIDFromHex(opponent.UserID)
if err != nil {
log.Printf("Error parsing opponent user ID: %v", err)
return
}
_, _, err := services.UpdateRatings(userID, opponentID, 0.0, time.Now())
if err != nil {
log.Printf("Error updating ratings after concede: %v", err)
// Notify clients of the rating update failure
errorMsg := Message{
Type: "error",
Content: "Rating update failed",
}
client.SafeWriteJSON(errorMsg)
opponent.SafeWriteJSON(errorMsg)
}
}
}

37 changes: 35 additions & 2 deletions frontend/src/Pages/DebateRoom.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { sendDebateMessage, judgeDebate } from "@/services/vsbot";
import { sendDebateMessage, judgeDebate, concedeDebate } from "@/services/vsbot";
import JudgmentPopup from "@/components/JudgementPopup";
import { Mic, MicOff } from "lucide-react";
import { useAtom } from "jotai";
Expand Down Expand Up @@ -218,6 +218,7 @@ const extractJSON = (response: string): string => {

const DebateRoom: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const debateData = location.state as DebateProps;
const phases = debateData.phaseTimings;
const debateKey = `debate_${debateData.userId}_${debateData.topic}_${debateData.debateId}`;
Expand Down Expand Up @@ -258,6 +259,30 @@ const DebateRoom: React.FC = () => {
const userAvatar =
user?.avatarUrl || "https://avatar.iran.liara.run/public/10";

const handleConcede = async () => {
if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
try {
if (debateData.debateId) {
await concedeDebate(debateData.debateId, state.messages);
}

setState(prev => ({ ...prev, isDebateEnded: true }));
setPopup({
show: true,
message: "You have conceded the debate.",
isJudging: false
});

setTimeout(() => {
navigate("/game");
}, 2000);

} catch (error) {
console.error("Error conceding:", error);
}
}
};
Comment on lines +262 to +284
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Improve error handling and state consistency.

The concede flow has a few concerns:

  1. State inconsistency risk: Line 269 sets isDebateEnded: true before the API call completes. If concedeDebate fails (line 266), the local state will show the debate as ended, but the backend won't reflect this change.
  2. Silent error handling: Lines 280-282 catch errors but only log them to console. Users won't know their concede action failed.
  3. Navigation timing: The 2-second delay (line 276) before navigating away might be too short for users to read the confirmation message.
🔎 Suggested improvements
 const handleConcede = async () => {
   if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
     try {
       if (debateData.debateId) {
           await concedeDebate(debateData.debateId, state.messages);
       }
       
+      // Only update state after successful API call
       setState(prev => ({ ...prev, isDebateEnded: true }));
       setPopup({
           show: true,
           message: "You have conceded the debate.",
           isJudging: false
       });
       
+      // Increase delay to give users more time to read the message
       setTimeout(() => {
           navigate("/game");
-      }, 2000);
+      }, 3000);

     } catch (error) {
       console.error("Error conceding:", error);
+      // Inform user of the error
+      setPopup({
+        show: true,
+        message: "Failed to concede the debate. Please try again.",
+        isJudging: false
+      });
     }
   }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleConcede = async () => {
if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
try {
if (debateData.debateId) {
await concedeDebate(debateData.debateId, state.messages);
}
setState(prev => ({ ...prev, isDebateEnded: true }));
setPopup({
show: true,
message: "You have conceded the debate.",
isJudging: false
});
setTimeout(() => {
navigate("/game");
}, 2000);
} catch (error) {
console.error("Error conceding:", error);
}
}
};
const handleConcede = async () => {
if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
try {
if (debateData.debateId) {
await concedeDebate(debateData.debateId, state.messages);
}
// Only update state after successful API call
setState(prev => ({ ...prev, isDebateEnded: true }});
setPopup({
show: true,
message: "You have conceded the debate.",
isJudging: false
});
// Increase delay to give users more time to read the message
setTimeout(() => {
navigate("/game");
}, 3000);
} catch (error) {
console.error("Error conceding:", error);
// Inform user of the error
setPopup({
show: true,
message: "Failed to concede the debate. Please try again.",
isJudging: false
});
}
}
};
🤖 Prompt for AI Agents
In frontend/src/Pages/DebateRoom.tsx around lines 262 to 284, the concede flow
updates local state and navigates before confirming the backend succeeded and
only logs errors; change the flow so the concede API is awaited first and only
on success setState(prev=>({...prev, isDebateEnded:true})), show a success
popup, then navigate (either after a longer timeout like 4s or after user
acknowledgment). In the catch block, replace the console.error with setting a
visible error popup (e.g., setPopup({show:true,message:"Failed to concede.
Please try again.",isError:true})) and do not change isDebateEnded or navigate
when the API fails; ensure all branches handle missing debateId gracefully by
showing an error popup instead of proceeding.


// Initialize SpeechRecognition
useEffect(() => {
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
Expand Down Expand Up @@ -800,6 +825,14 @@ const DebateRoom: React.FC = () => {
{user?.rating ? `Rating: ${user.rating}` : "Ready to argue!"}
</div>
</div>
{!state.isDebateEnded && (
<Button
onClick={handleConcede}
className="ml-auto bg-red-500 hover:bg-red-600 text-white rounded-md px-3 text-sm"
>
Concede
</Button>
)}
</div>
<div className="p-3 flex-1 overflow-y-auto">
<p className="text-sm font-semibold text-orange-600 mb-1">
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/Pages/OnlineDebateRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,25 @@ const OnlineDebateRoom = (): JSX.Element => {
startJudgmentPolling,
]);

const handleConcede = useCallback(() => {
if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
if (wsRef.current) {
wsRef.current.send(JSON.stringify({
type: "concede",
room: roomId,
userId: currentUserId,
username: currentUser?.displayName || "User"
}));
}
setDebatePhase(DebatePhase.Finished);
setPopup({
show: true,
message: "You have conceded the debate.",
isJudging: false,
});
}
}, [roomId, currentUserId, currentUser, setDebatePhase, setPopup]);
Comment on lines +800 to +817
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for WebSocket failures.

The concede flow updates local state without confirming the WebSocket message was sent successfully:

  1. Line 810 sets debatePhase to Finished immediately, but if wsRef.current is null or the WebSocket is disconnected, the concede message won't be sent to the opponent.
  2. No verification that the WebSocket send succeeded before updating UI state.
🔎 Suggested improvements
 const handleConcede = useCallback(() => {
   if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
-    if (wsRef.current) {
+    if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
       wsRef.current.send(JSON.stringify({
         type: "concede",
         room: roomId,
         userId: currentUserId,
         username: currentUser?.displayName || "User"
       }));
+      setDebatePhase(DebatePhase.Finished);
+      setPopup({
+        show: true,
+        message: "You have conceded the debate.",
+        isJudging: false,
+      });
+    } else {
+      // WebSocket not connected
+      setPopup({
+        show: true,
+        message: "Connection lost. Unable to concede. Please try again.",
+        isJudging: false,
+      });
+      return;
     }
-    setDebatePhase(DebatePhase.Finished);
-    setPopup({
-      show: true,
-      message: "You have conceded the debate.",
-      isJudging: false,
-    });
   }
 }, [roomId, currentUserId, currentUser, setDebatePhase, setPopup]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleConcede = useCallback(() => {
if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
if (wsRef.current) {
wsRef.current.send(JSON.stringify({
type: "concede",
room: roomId,
userId: currentUserId,
username: currentUser?.displayName || "User"
}));
}
setDebatePhase(DebatePhase.Finished);
setPopup({
show: true,
message: "You have conceded the debate.",
isJudging: false,
});
}
}, [roomId, currentUserId, currentUser, setDebatePhase, setPopup]);
const handleConcede = useCallback(() => {
if (window.confirm("Are you sure you want to concede? This will count as a loss.")) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: "concede",
room: roomId,
userId: currentUserId,
username: currentUser?.displayName || "User"
}));
setDebatePhase(DebatePhase.Finished);
setPopup({
show: true,
message: "You have conceded the debate.",
isJudging: false,
});
} else {
// WebSocket not connected
setPopup({
show: true,
message: "Connection lost. Unable to concede. Please try again.",
isJudging: false,
});
return;
}
}
}, [roomId, currentUserId, currentUser, setDebatePhase, setPopup]);
🤖 Prompt for AI Agents
In frontend/src/Pages/OnlineDebateRoom.tsx around lines 800 to 817, the concede
handler updates local state immediately without ensuring the WebSocket message
was actually sent; change the flow to (1) verify wsRef.current exists and
wsRef.current.readyState === WebSocket.OPEN before attempting to send, (2) wrap
the send in a try/catch and only call setDebatePhase(DebatePhase.Finished) and
the success popup after send completes without throwing, and (3) on failure (no
socket, not open, or send error) show an error popup and do not mark the debate
finished (optionally offer a retry). Ensure you include meaningful error text in
the popup/log to aid debugging.


const handlePhaseDone = useCallback(() => {
const currentIndex = phaseOrder.indexOf(debatePhase);
console.debug(
Expand Down Expand Up @@ -1241,6 +1260,14 @@ const OnlineDebateRoom = (): JSX.Element => {
}
}
break;
case "concede":
setDebatePhase(DebatePhase.Finished);
setPopup({
show: true,
message: `${data.username || "Opponent"} has conceded the debate. You win!`,
isJudging: false,
});
break;
case "spectatorJoined":
if (data.spectator?.connectionId) {
queueSpectatorOffer(
Expand Down Expand Up @@ -2090,6 +2117,16 @@ const OnlineDebateRoom = (): JSX.Element => {
</span>
)}
</p>
{debatePhase !== DebatePhase.Finished && debatePhase !== DebatePhase.Setup && (
<div className="mt-2">
<Button
onClick={handleConcede}
className="bg-red-500 hover:bg-red-600 text-white rounded-md px-3 text-sm"
>
Concede
</Button>
</div>
)}
</div>
</div>

Expand Down
17 changes: 17 additions & 0 deletions frontend/src/services/vsbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ export const sendDebateMessage = async (data: DebateRequest): Promise<{ response
return { response: result.response }; // Adjusted to return bot's response directly
};

export const concedeDebate = async (debateId: string, history: DebateMessage[] = []): Promise<void> => {
const token = getAuthToken();
const response = await fetch(`${baseURL}/vsbot/concede`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
credentials: "include",
body: JSON.stringify({ debateId, history }),
});

if (!response.ok) {
throw new Error("Failed to concede debate");
}
};

// Function to judge a debate
export const judgeDebate = async (data: JudgeRequest): Promise<JudgeResponse> => {
const token = getAuthToken();
Expand Down