diff --git a/backend/controllers/team_controller.go b/backend/controllers/team_controller.go index 62ae60c..6a6c173 100644 --- a/backend/controllers/team_controller.go +++ b/backend/controllers/team_controller.go @@ -366,11 +366,15 @@ func JoinTeam(c *gin.Context) { for _, member := range team.Members { totalElo += member.Elo } +<<<<<<< HEAD + if len(team.Members) >= team.MaxSize { +======= capacity := team.MaxSize if capacity <= 0 { capacity = 4 } if len(team.Members) >= capacity { +>>>>>>> main c.JSON(http.StatusBadRequest, gin.H{"error": "Team is already full"}) return } @@ -626,11 +630,19 @@ func GetAvailableTeams(c *gin.Context) { collection := db.GetCollection("teams") cursor, err := collection.Find(context.Background(), bson.M{ "$expr": bson.M{ +<<<<<<< HEAD + "$lt": []interface{}{ + bson.M{"$size": "$members"}, + "$maxSize", + }, + }, +======= "$lt": bson.A{ bson.M{"$size": "$members"}, "$maxSize", }, }, +>>>>>>> main }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve teams"}) diff --git a/backend/controllers/team_matchmaking.go b/backend/controllers/team_matchmaking.go index 34abe11..2786a2e 100644 --- a/backend/controllers/team_matchmaking.go +++ b/backend/controllers/team_matchmaking.go @@ -3,6 +3,7 @@ package controllers import ( "context" "net/http" + "time" "arguehub/db" "arguehub/models" @@ -32,7 +33,9 @@ func JoinMatchmaking(c *gin.Context) { // Get team collection := db.GetCollection("teams") var team models.Team - err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&team) + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + err = collection.FindOne(ctx, bson.M{"_id": objectID}).Decode(&team) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Team not found"}) return @@ -85,14 +88,16 @@ func LeaveMatchmaking(c *gin.Context) { collection := db.GetCollection("teams") var team models.Team - if err := collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&team); err != nil { + ctxLeave, cancelLeave := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancelLeave() + if err := collection.FindOne(ctxLeave, bson.M{"_id": objectID}).Decode(&team); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Team not found"}) return } userObjectID, ok := userID.(primitive.ObjectID) if !ok || team.CaptainID != userObjectID { - c.JSON(http.StatusForbidden, gin.H{"error": "Only the captain can remove the team from matchmaking"}) + c.JSON(http.StatusForbidden, gin.H{"error": "Only the captain can leave matchmaking"}) return } diff --git a/backend/middlewares/auth.go b/backend/middlewares/auth.go index cfa561b..aea9f8f 100644 --- a/backend/middlewares/auth.go +++ b/backend/middlewares/auth.go @@ -15,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" ) func AuthMiddleware(configPath string) gin.HandlerFunc { @@ -62,10 +63,16 @@ func AuthMiddleware(configPath string) gin.HandlerFunc { return } - email := claims["sub"].(string) + email, ok := claims["sub"].(string) + if !ok || email == "" { + log.Printf("Invalid or missing 'sub' claim in JWT") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"}) + c.Abort() + return + } // Fetch user from database - dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + dbCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() if db.MongoDatabase == nil { @@ -77,7 +84,12 @@ func AuthMiddleware(configPath string) gin.HandlerFunc { var user models.User err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": email}).Decode(&user) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + if errors.Is(err, mongo.ErrNoDocuments) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + } else { + log.Printf("Failed to load user %s: %v", email, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Authentication lookup failed"}) + } c.Abort() return } diff --git a/backend/models/team.go b/backend/models/team.go index edcd65e..87f8f1c 100644 --- a/backend/models/team.go +++ b/backend/models/team.go @@ -108,22 +108,22 @@ type TeamChatMessage struct { // MarshalJSON customizes JSON serialization for TeamDebate to convert ObjectIDs to hex strings func (td TeamDebate) MarshalJSON() ([]byte, error) { type Alias TeamDebate + var currentUserHex *string + if !td.CurrentUserID.IsZero() { + hex := td.CurrentUserID.Hex() + currentUserHex = &hex + } return json.Marshal(&struct { - ID string `json:"id,omitempty"` - Team1ID string `json:"team1Id"` - Team2ID string `json:"team2Id"` - CurrentUserID string `json:"currentUserId,omitempty"` + ID string `json:"id,omitempty"` + Team1ID string `json:"team1Id"` + Team2ID string `json:"team2Id"` + CurrentUserID *string `json:"currentUserId,omitempty"` *Alias }{ ID: td.ID.Hex(), Team1ID: td.Team1ID.Hex(), Team2ID: td.Team2ID.Hex(), - CurrentUserID: func() string { - if td.CurrentUserID.IsZero() { - return "" - } - return td.CurrentUserID.Hex() - }(), + CurrentUserID: currentUserHex, Alias: (*Alias)(&td), }) } diff --git a/backend/services/team_matchmaking.go b/backend/services/team_matchmaking.go index d70c341..29feb33 100644 --- a/backend/services/team_matchmaking.go +++ b/backend/services/team_matchmaking.go @@ -31,7 +31,9 @@ func StartTeamMatchmaking(teamID primitive.ObjectID) error { // Get team details collection := db.GetCollection("teams") var team models.Team - err := collection.FindOne(context.Background(), bson.M{"_id": teamID}).Decode(&team) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := collection.FindOne(ctx, bson.M{"_id": teamID}).Decode(&team) if err != nil { return err } @@ -66,7 +68,7 @@ func FindMatchingTeam(lookingTeamID primitive.ObjectID) (*models.Team, error) { defer teamMatchmakingMutex.RUnlock() if teamMatchmakingPool == nil { - teamMatchmakingPool = make(map[string]*TeamMatchmakingEntry) + return nil, mongo.ErrNoDocuments } lookingEntry, exists := teamMatchmakingPool[lookingTeamID.Hex()] @@ -116,8 +118,9 @@ func GetMatchmakingPool() map[string]*TeamMatchmakingEntry { return make(map[string]*TeamMatchmakingEntry) } snapshot := make(map[string]*TeamMatchmakingEntry, len(teamMatchmakingPool)) - for id, entry := range teamMatchmakingPool { - snapshot[id] = entry + for teamID, entry := range teamMatchmakingPool { + clone := *entry + snapshot[teamID] = &clone } return snapshot } diff --git a/backend/websocket/team_debate_handler.go b/backend/websocket/team_debate_handler.go index 126c920..729a607 100644 --- a/backend/websocket/team_debate_handler.go +++ b/backend/websocket/team_debate_handler.go @@ -1,6 +1,7 @@ package websocket import ( + "context" "encoding/json" "time" @@ -61,7 +62,7 @@ func TeamDebateHubRun() { // Load debate from database collection := db.GetCollection("team_debates") var debate models.TeamDebate - err := collection.FindOne(nil, bson.M{"_id": client.debateID}).Decode(&debate) + err := collection.FindOne(context.Background(), bson.M{"_id": client.debateID}).Decode(&debate) if err == nil { room = &TeamDebateRoom{ debate: debate, @@ -79,7 +80,6 @@ func TeamDebateHubRun() { room.team2Clients[client] = true } - // Send current debate state to new client room.broadcastToTeam(client, TeamDebateMessage{ Type: "state", Data: room.debate, @@ -194,7 +194,7 @@ func (c *TeamDebateClient) readPump() { Timestamp: time.Now(), } - _, err := collection.InsertOne(nil, msg) + _, err := collection.InsertOne(context.Background(), msg) if err != nil { } diff --git a/backend/websocket/team_websocket.go b/backend/websocket/team_websocket.go index 87b918d..4d746ab 100644 --- a/backend/websocket/team_websocket.go +++ b/backend/websocket/team_websocket.go @@ -3,8 +3,15 @@ package websocket import ( "context" "encoding/json" +<<<<<<< HEAD + "errors" + "log" + "net/http" + "os" +======= "log" "net/http" +>>>>>>> main "strings" "sync" "time" @@ -76,6 +83,8 @@ type TeamMessage struct { Room string `json:"room,omitempty"` Username string `json:"username,omitempty"` UserID string `json:"userId,omitempty"` + FromUserID string `json:"fromUserId,omitempty"` + TargetUserID string `json:"targetUserId,omitempty"` Content string `json:"content,omitempty"` Extra json.RawMessage `json:"extra,omitempty"` IsTyping bool `json:"isTyping,omitempty"` @@ -94,6 +103,9 @@ type TeamMessage struct { TeamID string `json:"teamId,omitempty"` Tokens int `json:"tokens,omitempty"` CanSpeak bool `json:"canSpeak,omitempty"` + Offer map[string]any `json:"offer,omitempty"` + Answer map[string]any `json:"answer,omitempty"` + Candidate map[string]any `json:"candidate,omitempty"` } var teamRooms = make(map[string]*TeamRoom) @@ -107,12 +119,17 @@ func TeamWebsocketHandler(c *gin.Context) { token = c.Query("token") } if token == "" { + log.Println("Team WebSocket connection failed: missing token") c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"}) return } // Validate token - valid, email, err := utils.ValidateTokenAndFetchEmail("./config/config.prod.yml", token, c) + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "./config/config.yml" + } + valid, email, err := utils.ValidateTokenAndFetchEmail(configPath, token, c) if err != nil || !valid || email == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) return @@ -120,6 +137,7 @@ func TeamWebsocketHandler(c *gin.Context) { debateID := c.Query("debateId") if debateID == "" { + log.Println("Team WebSocket connection failed: missing debateId parameter") c.JSON(http.StatusBadRequest, gin.H{"error": "Missing debateId parameter"}) return } @@ -182,42 +200,55 @@ func TeamWebsocketHandler(c *gin.Context) { } } - // Create or get team room - teamRoomsMutex.Lock() + // Prepare room outside lock roomKey := debateID - if _, exists := teamRooms[roomKey]; !exists { - turnManager := services.NewTeamTurnManager() - tokenBucket := services.NewTokenBucketService() - - // Initialize turn management for both teams - turnManager.InitializeTeamTurns(debate.Team1ID) - turnManager.InitializeTeamTurns(debate.Team2ID) - - // Initialize token buckets for both teams - tokenBucket.InitializeTeamBuckets(debate.Team1ID) - tokenBucket.InitializeTeamBuckets(debate.Team2ID) - - teamRooms[roomKey] = &TeamRoom{ - Clients: make(map[*websocket.Conn]*TeamClient), - Team1ID: debate.Team1ID, - Team2ID: debate.Team2ID, - DebateID: debateObjectID, - TurnManager: turnManager, - TokenBucket: tokenBucket, - CurrentTopic: debate.Topic, - CurrentPhase: "setup", - Team1Role: debate.Team1Stance, - Team2Role: debate.Team2Stance, - Team1Ready: make(map[string]bool), - Team2Ready: make(map[string]bool), - } - } - room := teamRooms[roomKey] + turnManager := services.NewTeamTurnManager() + tokenBucket := services.NewTokenBucketService() + + // Initialize turn management for both teams + turnErr1 := turnManager.InitializeTeamTurns(debate.Team1ID) + turnErr2 := turnManager.InitializeTeamTurns(debate.Team2ID) + + // Initialize token buckets for both teams + bucketErr1 := tokenBucket.InitializeTeamBuckets(debate.Team1ID) + bucketErr2 := tokenBucket.InitializeTeamBuckets(debate.Team2ID) + + if turnErr1 != nil || turnErr2 != nil || bucketErr1 != nil || bucketErr2 != nil { + log.Printf("error initializing room resources: %v %v %v %v", turnErr1, turnErr2, bucketErr1, bucketErr2) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize room resources"}) + return + } + + preparedRoom := &TeamRoom{ + Clients: make(map[*websocket.Conn]*TeamClient), + Team1ID: debate.Team1ID, + Team2ID: debate.Team2ID, + DebateID: debateObjectID, + TurnManager: turnManager, + TokenBucket: tokenBucket, + CurrentTopic: debate.Topic, + CurrentPhase: "setup", + Team1Role: debate.Team1Stance, + Team2Role: debate.Team2Stance, + Team1Ready: make(map[string]bool), + Team2Ready: make(map[string]bool), + } + + // Insert room if absent + teamRoomsMutex.Lock() + room, exists := teamRooms[roomKey] + if !exists { + teamRooms[roomKey] = preparedRoom + room = preparedRoom + } else { + // discard prepared room; existing room will be used + } teamRoomsMutex.Unlock() // Upgrade the connection conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { + log.Println("Team WebSocket upgrade error:", err) return } @@ -227,11 +258,14 @@ func TeamWebsocketHandler(c *gin.Context) { team2IDHex := debate.Team2ID.Hex() if userTeamIDHex != team1IDHex && userTeamIDHex != team2IDHex { + log.Printf("[TeamWebsocketHandler] ❌ ERROR: UserTeamID %s doesn't match Team1ID %s or Team2ID %s", userTeamIDHex, team1IDHex, team2IDHex) c.JSON(http.StatusInternalServerError, gin.H{"error": "Team assignment error"}) conn.Close() return } + log.Printf("[TeamWebsocketHandler] ✓ User %s belongs to team %s (Team1=%s, Team2=%s)", userObjectID.Hex(), userTeamIDHex, team1IDHex, team2IDHex) + // Create team client instance client := &TeamClient{ Conn: conn, @@ -322,6 +356,7 @@ func TeamWebsocketHandler(c *gin.Context) { messageType, msg, err := conn.ReadMessage() if err != nil { // Remove client from room + userID := client.UserID.Hex() room.Mutex.Lock() delete(room.Clients, conn) // If room is empty, delete it @@ -331,6 +366,12 @@ func TeamWebsocketHandler(c *gin.Context) { teamRoomsMutex.Unlock() } room.Mutex.Unlock() + + // Notify remaining clients that this user has left + broadcastExcept(room, conn, map[string]any{ + "type": "leave", + "userId": userID, + }) break } @@ -341,6 +382,12 @@ func TeamWebsocketHandler(c *gin.Context) { } // Update client activity + message.FromUserID = client.UserID.Hex() + message.UserID = client.UserID.Hex() + if message.TeamID == "" { + message.TeamID = client.TeamID.Hex() + } + room.Mutex.Lock() if client, exists := room.Clients[conn]; exists { client.LastActivity = time.Now() @@ -375,10 +422,19 @@ func TeamWebsocketHandler(c *gin.Context) { handleTeamTurnRequest(room, conn, message, client, roomKey) case "endTurn": handleTeamTurnEnd(room, conn, message, client, roomKey) + case "offer": + handleTeamWebRTCOffer(room, client, message) + case "answer": + handleTeamWebRTCAnswer(room, client, message) + case "candidate": + handleTeamWebRTCCandidate(room, client, message) + case "leave": + handleTeamLeave(room, client, roomKey) default: // Broadcast the message to all other clients in the room for _, r := range snapshotTeamRecipients(room, conn) { if err := r.SafeWriteMessage(messageType, msg); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -398,6 +454,52 @@ func snapshotTeamRecipients(room *TeamRoom, exclude *websocket.Conn) []*TeamClie return out } +<<<<<<< HEAD +// findClientByUserID returns the TeamClient matching the provided user ID +func findClientByUserID(room *TeamRoom, userID string) *TeamClient { + room.Mutex.Lock() + defer room.Mutex.Unlock() + for _, client := range room.Clients { + if client.UserID.Hex() == userID { + return client + } + } + return nil +} + +// sendMessageToUser sends a payload as JSON to the specified user if connected +func sendMessageToUser(room *TeamRoom, userID string, payload any) error { + target := findClientByUserID(room, userID) + if target == nil { + return errors.New("target user not connected") + } + return target.SafeWriteJSON(payload) +} + +// broadcastExcept sends a payload to every client except the provided connection +func broadcastExcept(room *TeamRoom, exclude *websocket.Conn, payload any) { + room.Mutex.Lock() + defer room.Mutex.Unlock() + for conn, client := range room.Clients { + if conn == exclude { + continue + } + if err := client.SafeWriteJSON(payload); err != nil { + log.Printf("Team WebSocket write error: %v", err) + } + } +} + +// broadcastAll sends identical payload to every connected client +func broadcastAll(room *TeamRoom, payload any) { + room.Mutex.Lock() + defer room.Mutex.Unlock() + for _, client := range room.Clients { + if err := client.SafeWriteJSON(payload); err != nil { + log.Printf("Team WebSocket write error: %v", err) + } + } +======= // snapshotAllTeamClients returns a slice of all team clients in the room. func snapshotAllTeamClients(room *TeamRoom) []*TeamClient { room.Mutex.Lock() @@ -407,6 +509,7 @@ func snapshotAllTeamClients(room *TeamRoom) []*TeamClient { out = append(out, cl) } return out +>>>>>>> main } // handleTeamJoin handles team join messages @@ -431,6 +534,7 @@ func handleTeamJoin(room *TeamRoom, conn *websocket.Conn, message TeamMessage, c "currentTurn": room.TurnManager.GetCurrentTurn(client.TeamID).Hex(), } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -461,6 +565,7 @@ func handleTeamChatMessage(room *TeamRoom, conn *websocket.Conn, message TeamMes "teamId": client.TeamID.Hex(), } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -485,6 +590,7 @@ func handleTeamDebateMessage(room *TeamRoom, conn *websocket.Conn, message TeamM "phase": message.Phase, } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -505,6 +611,7 @@ func handleTeamSpeakingIndicator(room *TeamRoom, conn *websocket.Conn, message T "teamId": client.TeamID.Hex(), } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -526,6 +633,7 @@ func handleTeamSpeechText(room *TeamRoom, conn *websocket.Conn, message TeamMess "teamId": client.TeamID.Hex(), } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -543,6 +651,7 @@ func handleTeamLiveTranscript(room *TeamRoom, conn *websocket.Conn, message Team "teamId": client.TeamID.Hex(), } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -551,9 +660,12 @@ func handleTeamLiveTranscript(room *TeamRoom, conn *websocket.Conn, message Team func handleTeamPhaseChange(room *TeamRoom, conn *websocket.Conn, message TeamMessage, roomKey string) { // Update room state room.Mutex.Lock() + oldPhase := room.CurrentPhase if message.Phase != "" { room.CurrentPhase = message.Phase + log.Printf("[handleTeamPhaseChange] Phase changed from %s to %s", oldPhase, room.CurrentPhase) } else { + log.Printf("[handleTeamPhaseChange] Received phase change message but Phase is empty") } currentPhase := room.CurrentPhase room.Mutex.Unlock() @@ -570,8 +682,13 @@ func handleTeamPhaseChange(room *TeamRoom, conn *websocket.Conn, message TeamMes for _, r := range snapshotAllTeamClients(room) { >>>>>>> main if err := r.SafeWriteJSON(phaseMessage); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } else { +<<<<<<< HEAD + log.Printf("[handleTeamPhaseChange] ✓ Phase change broadcasted: %s", room.CurrentPhase) +======= log.Printf("[handleTeamPhaseChange] ✓ Phase change broadcasted: %s", currentPhase) +>>>>>>> main } } } @@ -593,6 +710,7 @@ func handleTeamTopicChange(room *TeamRoom, conn *websocket.Conn, message TeamMes for _, r := range snapshotAllTeamClients(room) { >>>>>>> main if err := r.SafeWriteJSON(message); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -612,9 +730,12 @@ func handleTeamRoleSelection(room *TeamRoom, conn *websocket.Conn, message TeamM // Update team role based on which team the client belongs to if clientTeamIDHex == team1IDHex { room.Team1Role = message.Role + log.Printf("[handleTeamRoleSelection] Team1 role set to: %s by user %s", message.Role, client.UserID.Hex()) } else if clientTeamIDHex == team2IDHex { room.Team2Role = message.Role + log.Printf("[handleTeamRoleSelection] Team2 role set to: %s by user %s", message.Role, client.UserID.Hex()) } else { + log.Printf("[handleTeamRoleSelection] ERROR: Client TeamID %s doesn't match Team1ID %s or Team2ID %s", clientTeamIDHex, team1IDHex, team2IDHex) } // Broadcast role selection to ALL clients (including sender for sync) @@ -633,6 +754,7 @@ func handleTeamRoleSelection(room *TeamRoom, conn *websocket.Conn, message TeamM for _, r := range snapshotAllTeamClients(room) { >>>>>>> main if err := r.SafeWriteJSON(roleMessage); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } else { @@ -647,6 +769,7 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes client, exists := room.Clients[conn] if !exists { room.Mutex.Unlock() + log.Printf("[handleTeamReadyStatus] ERROR: Client not found for connection") return } @@ -656,8 +779,12 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes team1IDHex := room.Team1ID.Hex() team2IDHex := room.Team2ID.Hex() + log.Printf("[handleTeamReadyStatus] User %s (TeamID: %s) setting ready=%v", userID, clientTeamIDHex, message.Ready) + log.Printf("[handleTeamReadyStatus] Room Team1ID: %s, Team2ID: %s", team1IDHex, team2IDHex) + if message.Ready == nil { room.Mutex.Unlock() + log.Printf("[handleTeamReadyStatus] ERROR: message.Ready is nil") return } @@ -678,12 +805,15 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes // User belongs to Team 1 - assign ONLY to Team1Ready room.Team1Ready[userID] = *message.Ready assignedToTeam = "Team1" + log.Printf("[handleTeamReadyStatus] ✓✓✓ ASSIGNED TO Team1Ready ONLY. User %s (TeamID: %s) ready=%v", userID, clientTeamIDHex, *message.Ready) } else if clientTeamIDHex == team2IDHex { // User belongs to Team 2 - assign ONLY to Team2Ready room.Team2Ready[userID] = *message.Ready assignedToTeam = "Team2" + log.Printf("[handleTeamReadyStatus] ✓✓✓ ASSIGNED TO Team2Ready ONLY. User %s (TeamID: %s) ready=%v", userID, clientTeamIDHex, *message.Ready) } else { // CRITICAL ERROR: TeamID doesn't match - this should NEVER happen + log.Printf("[handleTeamReadyStatus] ❌❌❌ CRITICAL ERROR: User %s TeamID %s doesn't match Team1ID %s or Team2ID %s - NOT ASSIGNING READY", userID, clientTeamIDHex, team1IDHex, team2IDHex) room.Mutex.Unlock() return } @@ -717,6 +847,9 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes } } + log.Printf("[handleTeamReadyStatus] Current counts - Team1Ready=%d/%d, Team2Ready=%d/%d", + currentTeam1ReadyCount, currentTeam1MembersCount, currentTeam2ReadyCount, currentTeam2MembersCount) + // Broadcast ready status with accurate counts to ALL clients readyMessage := map[string]interface{}{ "type": "ready", @@ -730,8 +863,19 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes "team2MembersCount": currentTeam2MembersCount, // Use accurate counts } + log.Printf("[handleTeamReadyStatus] Broadcasting ready status: User %s assigned to %s, Team1Ready=%d/%d, Team2Ready=%d/%d", + userID, assignedToTeam, currentTeam1ReadyCount, currentTeam1MembersCount, currentTeam2ReadyCount, currentTeam2MembersCount) + + // Log the actual message being sent to verify counts are included + readyMessageJSON, _ := json.Marshal(readyMessage) + log.Printf("[handleTeamReadyStatus] Ready message JSON: %s", string(readyMessageJSON)) + for _, r := range room.Clients { - _ = r.SafeWriteJSON(readyMessage) + if err := r.SafeWriteJSON(readyMessage); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) + } else { + log.Printf("[handleTeamReadyStatus] ✓ Ready message sent successfully") + } } // Check if all teams are ready and phase is still setup @@ -739,12 +883,18 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes allTeam2Ready := currentTeam2ReadyCount == currentTeam2MembersCount && currentTeam2MembersCount > 0 allReady := allTeam1Ready && allTeam2Ready + log.Printf("[handleTeamReadyStatus] Ready check: Team1=%d/%d ready=%v, Team2=%d/%d ready=%v, AllReady=%v, Phase=%s", + currentTeam1ReadyCount, currentTeam1MembersCount, allTeam1Ready, + currentTeam2ReadyCount, currentTeam2MembersCount, allTeam2Ready, + allReady, room.CurrentPhase) + // Check if we should start countdown - use a flag to prevent multiple triggers shouldStartCountdown := allReady && room.CurrentPhase == "setup" // Check if countdown already started (phase is still setup but we have a flag in room) // We'll use a simple check: if phase is setup and all ready, start countdown if shouldStartCountdown { + log.Printf("[handleTeamReadyStatus] ✓ All teams ready and phase is setup - starting countdown") // Broadcast countdown start to ALL clients immediately countdownMessage := map[string]interface{}{ @@ -752,8 +902,13 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes "countdown": 3, } for _, r := range room.Clients { - _ = r.SafeWriteJSON(countdownMessage) + if err := r.SafeWriteJSON(countdownMessage); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) + } else { + log.Printf("[handleTeamReadyStatus] ✓ Countdown message sent to client") + } } + log.Printf("[handleTeamReadyStatus] All teams ready! Starting countdown for %d clients", len(room.Clients)) // Update phase immediately to prevent multiple triggers room.CurrentPhase = "countdown" @@ -767,6 +922,7 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes teamRoomsMutex.Unlock() if !stillExists { + log.Printf("[handleTeamReadyStatus] Room %s no longer exists, aborting phase change", roomKey) return } @@ -781,14 +937,19 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes } for _, r := range room.Clients { if err := r.SafeWriteJSON(phaseMessage); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } else { + log.Printf("[handleTeamReadyStatus] ✓ Phase change message sent to client") } } + log.Printf("[handleTeamReadyStatus] Debate started! Phase changed to openingFor for %d clients", len(room.Clients)) } else { + log.Printf("[handleTeamReadyStatus] Phase already changed to %s, skipping", room.CurrentPhase) } room.Mutex.Unlock() }() } else { + log.Printf("[handleTeamReadyStatus] Not starting countdown: allReady=%v, phase=%s", allReady, room.CurrentPhase) } room.Mutex.Unlock() @@ -836,6 +997,7 @@ func handleTeamTurnRequest(room *TeamRoom, conn *websocket.Conn, message TeamMes "currentTurn": currentTurn, } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -877,6 +1039,7 @@ func handleTeamTurnEnd(room *TeamRoom, conn *websocket.Conn, message TeamMessage "currentTurn": nextUserID.Hex(), } if err := r.SafeWriteJSON(response); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } } @@ -888,6 +1051,7 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { defer room.Mutex.Unlock() if room.CurrentPhase != "setup" { + log.Printf("[handleCheckStart] Phase is %s, not setup - ignoring", room.CurrentPhase) return } @@ -925,7 +1089,13 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { allTeam2Ready := team2ReadyCount == team2MembersCount && team2MembersCount > 0 allReady := allTeam1Ready && allTeam2Ready + log.Printf("[handleCheckStart] Check: Team1=%d/%d ready=%v, Team2=%d/%d ready=%v, AllReady=%v", + team1ReadyCount, team1MembersCount, allTeam1Ready, + team2ReadyCount, team2MembersCount, allTeam2Ready, + allReady) + if allReady && room.CurrentPhase == "setup" { + log.Printf("[handleCheckStart] ✓✓✓ ALL TEAMS READY! Starting countdown...") // Update phase to prevent multiple triggers room.CurrentPhase = "countdown" @@ -936,7 +1106,11 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { "countdown": 3, } for _, r := range room.Clients { - _ = r.SafeWriteJSON(countdownMessage) + if err := r.SafeWriteJSON(countdownMessage); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) + } else { + log.Printf("[handleCheckStart] ✓ Countdown message sent") + } } // Start countdown and phase change after 3 seconds @@ -948,6 +1122,7 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { teamRoomsMutex.Unlock() if !stillExists { + log.Printf("[handleCheckStart] Room %s no longer exists", roomKey) return } @@ -961,10 +1136,89 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { Phase: "openingFor", } for _, r := range room.Clients { - _ = r.SafeWriteJSON(phaseMessage) + if err := r.SafeWriteJSON(phaseMessage); err != nil { + log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) + } else { + log.Printf("[handleCheckStart] ✓ Phase change sent") + } } + log.Printf("[handleCheckStart] Debate started! Phase changed to openingFor") } room.Mutex.Unlock() }() + } else { + log.Printf("[handleCheckStart] Not all ready: Team1=%d/%d, Team2=%d/%d", + team1ReadyCount, team1MembersCount, team2ReadyCount, team2MembersCount) + } +} + +// handleTeamWebRTCOffer relays an SDP offer to the intended target user +func handleTeamWebRTCOffer(room *TeamRoom, client *TeamClient, message TeamMessage) { + if message.TargetUserID == "" || message.Offer == nil { + log.Printf("[handleTeamWebRTCOffer] Missing offer or target for user %s", client.UserID.Hex()) + return + } + + payload := map[string]any{ + "type": "offer", + "fromUserId": client.UserID.Hex(), + "targetUserId": message.TargetUserID, + "teamId": client.TeamID.Hex(), + "offer": message.Offer, + } + + if err := sendMessageToUser(room, message.TargetUserID, payload); err != nil { + log.Printf("[handleTeamWebRTCOffer] Failed forwarding offer from %s to %s: %v", client.UserID.Hex(), message.TargetUserID, err) + } +} + +// handleTeamWebRTCAnswer relays an SDP answer to the offer initiator +func handleTeamWebRTCAnswer(room *TeamRoom, client *TeamClient, message TeamMessage) { + if message.TargetUserID == "" || message.Answer == nil { + log.Printf("[handleTeamWebRTCAnswer] Missing answer or target for user %s", client.UserID.Hex()) + return + } + + payload := map[string]any{ + "type": "answer", + "fromUserId": client.UserID.Hex(), + "targetUserId": message.TargetUserID, + "teamId": client.TeamID.Hex(), + "answer": message.Answer, + } + + if err := sendMessageToUser(room, message.TargetUserID, payload); err != nil { + log.Printf("[handleTeamWebRTCAnswer] Failed forwarding answer from %s to %s: %v", client.UserID.Hex(), message.TargetUserID, err) + } +} + +// handleTeamWebRTCCandidate relays ICE candidates during WebRTC negotiation +func handleTeamWebRTCCandidate(room *TeamRoom, client *TeamClient, message TeamMessage) { + if message.TargetUserID == "" || message.Candidate == nil { + log.Printf("[handleTeamWebRTCCandidate] Missing candidate or target for user %s", client.UserID.Hex()) + return + } + + payload := map[string]any{ + "type": "candidate", + "fromUserId": client.UserID.Hex(), + "targetUserId": message.TargetUserID, + "teamId": client.TeamID.Hex(), + "candidate": message.Candidate, + } + + if err := sendMessageToUser(room, message.TargetUserID, payload); err != nil { + log.Printf("[handleTeamWebRTCCandidate] Failed forwarding candidate from %s to %s: %v", client.UserID.Hex(), message.TargetUserID, err) + } +} + +// handleTeamLeave notifies all clients that a participant has left voluntarily +func handleTeamLeave(room *TeamRoom, client *TeamClient, roomKey string) { + payload := map[string]any{ + "type": "leave", + "userId": client.UserID.Hex(), + "teamId": client.TeamID.Hex(), } + broadcastAll(room, payload) + log.Printf("[handleTeamLeave] User %s left room %s", client.UserID.Hex(), roomKey) } diff --git a/frontend/src/Pages/Game.tsx b/frontend/src/Pages/Game.tsx index 4fd8c38..3493ced 100644 --- a/frontend/src/Pages/Game.tsx +++ b/frontend/src/Pages/Game.tsx @@ -44,46 +44,21 @@ const Game: React.FC = () => { } }, []); - const upsertIndicator = ( - indicators: TypingIndicator[], - update: { - userId: string; - username?: string; - isTyping?: boolean; - isSpeaking?: boolean; - partialText?: string; - } - ) => { - const existing = indicators.find( - (indicator) => indicator.userId === update.userId - ); - const nextIndicator: TypingIndicator = { - userId: update.userId, - username: update.username ?? existing?.username ?? "Opponent", - isTyping: update.isTyping ?? existing?.isTyping ?? false, - isSpeaking: update.isSpeaking ?? existing?.isSpeaking ?? false, - partialText: - update.partialText !== undefined - ? update.partialText - : existing?.partialText, - }; - - if ( - !nextIndicator.isTyping && - !nextIndicator.isSpeaking && - !nextIndicator.partialText - ) { - return indicators.filter( - (indicator) => indicator.userId !== update.userId - ); - } - - const filtered = indicators.filter( - (indicator) => indicator.userId !== update.userId - ); - return [...filtered, nextIndicator]; + type GameWebSocketMessage = { + type: string; + content?: string; + [key: string]: unknown; }; +<<<<<<< HEAD + const parseContent = useCallback( + (raw: string, messageType: string): T | null => { + try { + return JSON.parse(raw) as T; + } catch (error) { + console.error(`Failed to parse ${messageType} content:`, error); + return null; +======= const handleWebSocketMessage = (message: any) => { switch (message.type) { case "DEBATE_START": @@ -164,27 +139,222 @@ const Game: React.FC = () => { transcriptStatus: { loading: true, isUser: sender === userId }, //transcript is getting generated })); break; +>>>>>>> main } + }, + [] + ); - case "GAME_RESULT": { - const { winnerUserId, points, totalPoints, evaluationMessage } = - JSON.parse(message.content); - setState((prevState) => ({ - ...prevState, - gameResult: { - isReady: true, - isWinner: winnerUserId === userId, - points: points, - totalPoints: totalPoints, - evaluationMessage: evaluationMessage, - }, - })); - break; - } + const handleWebSocketMessage = useCallback( + (message: GameWebSocketMessage) => { + switch (message.type) { + case "DEBATE_START": + setState((prevState) => ({ ...prevState, loading: false })); + break; + case "DEBATE_END": + setState((prevState) => ({ ...prevState, gameEnded: true })); + break; + case "TURN_START": { + if (!message.content) { + console.warn("TURN_START received without content"); + break; + } + const parsed = parseContent<{ currentTurn: string; duration: number }>( + message.content, + "TURN_START" + ); + if (!parsed) { + break; + } + const { currentTurn, duration } = parsed; + setState((prevState) => ({ + ...prevState, + isTurn: currentTurn === userId, + turnDuration: duration, + })); + break; + } + case "TURN_END": + setState((prevState) => ({ + ...prevState, + isTurn: false, + turnDuration: 0, + })); + break; + case "CHAT_MESSAGE": { + if (!message.content) { + console.warn("CHAT_MESSAGE received without content"); + break; + } + const parsed = parseContent<{ + sender: string; + message: string; + username?: string; + }>(message.content, "CHAT_MESSAGE"); + if (!parsed) { + break; + } + const { sender, message: chatMessage } = parsed; + const newMessage: ChatMessage = { + isUser: sender === userId, + text: chatMessage, + }; + setState((prevState) => ({ + ...prevState, + messages: [...prevState.messages, newMessage], + transcriptStatus: { ...prevState.transcriptStatus, loading: false }, + typingIndicators: prevState.typingIndicators.filter( + (indicator) => indicator.userId !== sender + ), + })); + break; + } + case "GENERATING_TRANSCRIPT": { + if (!message.content) { + console.warn("GENERATING_TRANSCRIPT received without content"); + break; + } + const parsed = parseContent<{ sender: string }>( + message.content, + "GENERATING_TRANSCRIPT" + ); + if (!parsed) { + break; + } + const { sender } = parsed; + setState((prevState) => ({ + ...prevState, + transcriptStatus: { loading: true, isUser: sender === userId }, //transcript is getting generated + })); + break; + } - default: - } - }; + case "TYPING_START": + case "TYPING_STOP": { + if (!message.content) { + console.warn(`${message.type} received without content`); + break; + } + const parsed = parseContent<{ + userId: string; + username?: string; + partialText?: string; + }>(message.content, message.type); + if (!parsed || !parsed.userId || parsed.userId === userId) { + break; + } + const isTyping = message.type === "TYPING_START"; + setState((prevState) => { + const existing = prevState.typingIndicators.find( + (indicator) => indicator.userId === parsed.userId + ); + const others = prevState.typingIndicators.filter( + (indicator) => indicator.userId !== parsed.userId + ); + const baseIndicator: TypingIndicator = + existing ?? { + userId: parsed.userId, + username: parsed.username ?? "Opponent", + isTyping: false, + isSpeaking: false, + }; + const updatedIndicator: TypingIndicator = { + ...baseIndicator, + username: parsed.username ?? baseIndicator.username, + isTyping, + partialText: isTyping ? parsed.partialText : undefined, + }; + if (!updatedIndicator.isTyping && !updatedIndicator.isSpeaking) { + return { ...prevState, typingIndicators: others }; + } + return { + ...prevState, + typingIndicators: [...others, updatedIndicator], + }; + }); + break; + } + + case "SPEAKING_START": + case "SPEAKING_STOP": { + if (!message.content) { + console.warn(`${message.type} received without content`); + break; + } + const parsed = parseContent<{ + userId: string; + username?: string; + }>(message.content, message.type); + if (!parsed || !parsed.userId || parsed.userId === userId) { + break; + } + const isSpeaking = message.type === "SPEAKING_START"; + setState((prevState) => { + const existing = prevState.typingIndicators.find( + (indicator) => indicator.userId === parsed.userId + ); + const others = prevState.typingIndicators.filter( + (indicator) => indicator.userId !== parsed.userId + ); + const baseIndicator: TypingIndicator = + existing ?? { + userId: parsed.userId, + username: parsed.username ?? "Opponent", + isTyping: false, + isSpeaking: false, + }; + const updatedIndicator: TypingIndicator = { + ...baseIndicator, + username: parsed.username ?? baseIndicator.username, + isSpeaking, + }; + if (!updatedIndicator.isTyping && !updatedIndicator.isSpeaking) { + return { ...prevState, typingIndicators: others }; + } + return { + ...prevState, + typingIndicators: [...others, updatedIndicator], + }; + }); + break; + } + + case "GAME_RESULT": { + console.log(message); + if (!message.content) { + console.warn("GAME_RESULT received without content"); + break; + } + const parsed = parseContent<{ + winnerUserId: string; + points: number; + totalPoints: number; + evaluationMessage: string; + }>(message.content, "GAME_RESULT"); + if (!parsed) { + break; + } + const { winnerUserId, points, totalPoints, evaluationMessage } = + parsed; + setState((prevState) => ({ + ...prevState, + gameResult: { + isReady: true, + isWinner: winnerUserId === userId, + points: points, + totalPoints: totalPoints, + evaluationMessage: evaluationMessage, + }, + })); + break; + } + + default: + console.warn("Unhandled message type:", message.type); + } + }, + [userId, parseContent] + ); useEffect(() => { const wsURL = `${import.meta.env.VITE_BASE_URL}/ws?userId=${userId}`; @@ -192,9 +362,21 @@ const Game: React.FC = () => { ws.binaryType = "arraybuffer"; websocketRef.current = ws; - ws.onmessage = (event) => handleWebSocketMessage(JSON.parse(event.data)); + ws.onopen = () => console.log("WebSocket connection established"); + ws.onmessage = (event) => { + try { + handleWebSocketMessage(JSON.parse(event.data)); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + ws.onerror = (error) => console.error("WebSocket error:", error); + ws.onclose = () => console.log("WebSocket connection closed"); return () => ws.close(); +<<<<<<< HEAD + }, [userId, handleWebSocketMessage]); +======= }, [userId]); const handleSendChatMessage = useCallback( @@ -270,6 +452,7 @@ const Game: React.FC = () => { }, [sendWebSocketMessage, userId] ); +>>>>>>> main const renderGameContent = () => (
@@ -343,12 +526,74 @@ const Game: React.FC = () => { { + if (!message.trim()) return; + const payload = { + type: "CHAT_MESSAGE", + content: JSON.stringify({ + sender: userId, + message, + mode, + }), + timestamp: Date.now(), + }; + + if ( + websocketRef.current && + websocketRef.current.readyState === WebSocket.OPEN + ) { + websocketRef.current.send(JSON.stringify(payload)); + } + + setState((prev) => ({ + ...prev, + messages: [...prev.messages, { isUser: true, text: message }], + })); + }} + onTypingChange={(isTyping, partialText) => { + if ( + !websocketRef.current || + websocketRef.current.readyState !== WebSocket.OPEN + ) { + return; + } + + websocketRef.current.send( + JSON.stringify({ + type: isTyping ? "TYPING_START" : "TYPING_STOP", + content: JSON.stringify({ + userId, + partialText: isTyping ? partialText : undefined, + }), + timestamp: Date.now(), + }) + ); + }} + onSpeakingChange={(isSpeaking) => { + if ( + !websocketRef.current || + websocketRef.current.readyState !== WebSocket.OPEN + ) { + return; + } + + websocketRef.current.send( + JSON.stringify({ + type: isSpeaking ? "SPEAKING_START" : "SPEAKING_STOP", + content: JSON.stringify({ + userId, + }), + timestamp: Date.now(), + }) + ); + }} typingIndicators={state.typingIndicators} isMyTurn={state.isTurn} +<<<<<<< HEAD + disabled={!(state.isTurn && !state.gameEnded)} +======= disabled={state.gameEnded || state.loading} +>>>>>>> main />
diff --git a/frontend/src/Pages/MatchLogs.tsx b/frontend/src/Pages/MatchLogs.tsx index d07190d..3e8099b 100644 --- a/frontend/src/Pages/MatchLogs.tsx +++ b/frontend/src/Pages/MatchLogs.tsx @@ -107,23 +107,27 @@ const MatchLogs: React.FC = () => { : log.match.includes("Semifinal") ? "Semifinal" : "Final"; - const isFirstRoundMatch3 = log.match.startsWith( - "First Round Match 3: Ayaan Khanna vs Vivaan Sharma" - ); let winner = ""; if (log.score && log.score.total) { const [score1, score2] = log.score.total.split("-").map(Number); if (score1 > score2) winner = player1.split(": ")[1]; else if (score2 > score1) winner = player2; +<<<<<<< HEAD + else { + winner = log.match.includes("First Round Match 3") + ? "Ayaan Khanna (Tiebreaker)" + : ""; + } +======= else winner = isFirstRoundMatch3 ? "Ayaan Khanna (Tiebreaker)" : ""; +>>>>>>> main } return { player1: player1.split(": ")[1] || player1, player2, stage, winner, - isFirstRoundMatch3, }; }; @@ -132,8 +136,7 @@ const MatchLogs: React.FC = () => {

Match Logs

{[...logs].reverse().map((log, index) => { - const { player1, player2, stage, winner, isFirstRoundMatch3 } = - getMatchDetails(log); + const { player1, player2, stage, winner } = getMatchDetails(log); return (
{ {log.score?.total.split("-")[1]}
- {isFirstRoundMatch3 && ( + {stage === "First Round Match 3" && (

* Ayaan Khanna advanced via tiebreaker

diff --git a/frontend/src/Pages/TeamBuilder.tsx b/frontend/src/Pages/TeamBuilder.tsx index e06eb6d..47671e4 100644 --- a/frontend/src/Pages/TeamBuilder.tsx +++ b/frontend/src/Pages/TeamBuilder.tsx @@ -1,6 +1,4 @@ import React, { useState, useCallback } from "react"; -import { useAtom } from "jotai"; -import { userAtom } from "@/state/userAtom"; import { createTeam, getAvailableTeams, @@ -19,6 +17,7 @@ import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; +import { useUser } from "@/hooks/useUser"; import { Dialog, DialogContent, @@ -81,7 +80,7 @@ interface SelectedMember { } const TeamBuilder: React.FC = () => { - const [user] = useAtom(userAtom); + const { user } = useUser(); const [teamName, setTeamName] = useState(""); const [maxSize, setMaxSize] = useState(4); const [isCreating, setIsCreating] = useState(false); @@ -631,6 +630,22 @@ const TeamBuilder: React.FC = () => {
{/* Team Matchmaking */} +<<<<<<< HEAD +
+ +
+======= {user && user.id && (
{ />
)} +>>>>>>> main
{(team.members || []).map((member: TeamMember) => ( @@ -821,8 +837,12 @@ const TeamBuilder: React.FC = () => {
{availableTeams.map((team) => { const memberCount = team.members?.length || 0; +<<<<<<< HEAD + const capacity = team.maxSize || 4; +======= const capacity = team.maxSize && team.maxSize > 0 ? team.maxSize : 4; +>>>>>>> main const isFull = memberCount >= capacity; return ( diff --git a/frontend/src/Pages/TeamDebateRoom.tsx b/frontend/src/Pages/TeamDebateRoom.tsx index c86786e..e17e31d 100644 --- a/frontend/src/Pages/TeamDebateRoom.tsx +++ b/frontend/src/Pages/TeamDebateRoom.tsx @@ -86,6 +86,8 @@ interface WSMessage { userId?: string; username?: string; timestamp?: number; + fromUserId?: string; + targetUserId?: string; mode?: "type" | "speak"; isTyping?: boolean; isSpeaking?: boolean; @@ -144,6 +146,18 @@ const TeamDebateRoom: React.FC = () => { // Use user from hook if available, otherwise fallback to atom const currentUser = userFromHook || user; + // Debug: Log user state + useEffect(() => { + console.log("[TeamDebateRoom] User state:", { + userFromAtom: user?.id, + userFromHook: userFromHook?.id, + currentUser: currentUser?.id, + isUserLoading, + isAuthenticated, + hasToken: !!getAuthToken() + }); + }, [user?.id, userFromHook?.id, currentUser?.id, isUserLoading, isAuthenticated]); + // Debate state const [debate, setDebate] = useState(null); const [topic, setTopic] = useState(""); @@ -169,6 +183,7 @@ const TeamDebateRoom: React.FC = () => { // Track individual ready status for each player const [playerReadyStatus, setPlayerReadyStatus] = useState>(new Map()); + const [hasDeterminedTeam, setHasDeterminedTeam] = useState(false); // Refs for WebSocket, PeerConnections, and media elements const wsRef = useRef(null); @@ -184,6 +199,10 @@ const TeamDebateRoom: React.FC = () => { }, [debatePhase]); // State for media streams + const [localStream, setLocalStream] = useState(null); + const [remoteStreams, setRemoteStreams] = useState< + Map + >(new Map()); const [mediaError, setMediaError] = useState(null); const [isCameraOn, setIsCameraOn] = useState(true); @@ -202,6 +221,7 @@ const TeamDebateRoom: React.FC = () => { useEffect(() => { currentUserIdRef.current = currentUser?.id; }, [currentUser?.id]); +>>>>>>> main // Timer state const [timer, setTimer] = useState(0); @@ -209,12 +229,13 @@ const TeamDebateRoom: React.FC = () => { // Speech recognition state const [isListening, setIsListening] = useState(false); - const [, setCurrentTranscript] = useState(""); + const [currentTranscript, setCurrentTranscript] = useState(""); const recognitionRef = useRef(null); - const [, setSpeechError] = useState(null); + const [speechError, setSpeechError] = useState(null); const [speechTranscripts, setSpeechTranscripts] = useState<{ [key: string]: string; }>({}); + const [speechRecognitionDisabled, setSpeechRecognitionDisabled] = useState(false); // Popup and countdown state const [showSetupPopup, setShowSetupPopup] = useState(true); @@ -244,6 +265,276 @@ const TeamDebateRoom: React.FC = () => { "Is online learning as effective as traditional education?", ]; + const toggleCamera = useCallback(async () => { + const shouldEnable = !isCameraOn; + + // Acquire a stream if we're turning the camera back on after it was released + if (shouldEnable && !localStreamRef.current) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: 1280, height: 720 }, + audio: true, + }); + localStreamRef.current = stream; + setLocalStream(stream); + + const localVideoEl = localVideoRefs.current.get(currentUser?.id || ""); + if (localVideoEl) { + localVideoEl.srcObject = stream; + } + } catch (error) { + console.error("toggleCamera: failed to enable camera", error); + setMediaError( + "Unable to access the camera. Please check permissions and try again." + ); + return; + } + } + + const stream = localStreamRef.current; + if (!stream) { + console.warn("toggleCamera called without an active local stream."); + return; + } + + stream.getVideoTracks().forEach((track) => { + track.enabled = shouldEnable; + }); + + setIsCameraOn(shouldEnable); + if (shouldEnable) { + setMediaError(null); + } + }, [currentUser?.id, isCameraOn, setIsCameraOn]); + + const currentUserIdRef = useRef(currentUser?.id); + const isTeam1Ref = useRef(isTeam1); + const debatePhaseRef = useRef(debatePhase); + + useEffect(() => { + currentUserIdRef.current = currentUser?.id; + }, [currentUser?.id]); + + useEffect(() => { + isTeam1Ref.current = isTeam1; + }, [isTeam1]); + + useEffect(() => { + debatePhaseRef.current = debatePhase; + }, [debatePhase]); + + const pendingCandidatesRef = useRef>(new Map()); + const initiatedOffersRef = useRef>(new Set()); + + const sendSignalMessage = useCallback((message: Record) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)); + } else { + console.warn("WebSocket not ready for signalling message:", message); + } + }, []); + + const attachStreamToVideo = useCallback((userId: string, stream: MediaStream) => { + const videoEl = remoteVideoRefs.current.get(userId); + if (videoEl && videoEl.srcObject !== stream) { + videoEl.srcObject = stream; + if (typeof videoEl.play === "function") { + videoEl + .play() + .catch((err) => + console.warn("Unable to autoplay remote video element", err) + ); + } + } + }, []); + + const closePeerConnection = useCallback( + (remoteUserId: string) => { + const pc = pcRefs.current.get(remoteUserId); + if (pc) { + try { + pc.ontrack = null; + pc.onicecandidate = null; + pc.oniceconnectionstatechange = null; + pc.close(); + } catch (error) { + console.warn("Error closing peer connection", error); + } + pcRefs.current.delete(remoteUserId); + } + pendingCandidatesRef.current.delete(remoteUserId); + initiatedOffersRef.current.delete(remoteUserId); + + setRemoteStreams((prev) => { + if (!prev.has(remoteUserId)) return prev; + const updated = new Map(prev); + updated.delete(remoteUserId); + return updated; + }); + remoteVideoRefs.current.delete(remoteUserId); + }, + [setRemoteStreams] + ); + + const createPeerConnection = useCallback( + (remoteUserId: string): RTCPeerConnection | undefined => { + if (pcRefs.current.has(remoteUserId)) { + return pcRefs.current.get(remoteUserId); + } + + if (!localStreamRef.current) { + console.warn("Attempted to create peer connection without local media stream"); + return undefined; + } + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + }); + + pcRefs.current.set(remoteUserId, pc); + + pc.onicecandidate = (event) => { + if (event.candidate && currentUserIdRef.current) { + const serialisedCandidate = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + }; + sendSignalMessage({ + type: "candidate", + fromUserId: currentUserIdRef.current, + targetUserId: remoteUserId, + candidate: serialisedCandidate, + }); + } + }; + + pc.ontrack = (event) => { + const [stream] = event.streams; + if (!stream) return; + setRemoteStreams((prev) => { + const updated = new Map(prev); + updated.set(remoteUserId, stream); + return updated; + }); + attachStreamToVideo(remoteUserId, stream); + }; + + pc.oniceconnectionstatechange = () => { + const state = pc.iceConnectionState; + if (state === "failed" || state === "disconnected" || state === "closed") { + closePeerConnection(remoteUserId); + } + }; + + localStreamRef.current.getTracks().forEach((track) => { + pc.addTrack(track, localStreamRef.current as MediaStream); + }); + + return pc; + }, + [attachStreamToVideo, closePeerConnection, sendSignalMessage] + ); + + const flushPendingCandidates = useCallback(async (remoteUserId: string) => { + const pc = pcRefs.current.get(remoteUserId); + const pending = pendingCandidatesRef.current.get(remoteUserId); + if (!pc || !pending || !pc.remoteDescription) return; + + for (const candidate of pending) { + try { + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (error) { + console.error("Failed to apply pending ICE candidate", error); + } + } + pendingCandidatesRef.current.delete(remoteUserId); + }, []); + + const initiateOffer = useCallback( + async (remoteUserId: string) => { + const currentUserId = currentUserIdRef.current; + if (!currentUserId) return; + + let pc = pcRefs.current.get(remoteUserId); + if (!pc) { + pc = createPeerConnection(remoteUserId); + } + if (!pc) return; + + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + sendSignalMessage({ + type: "offer", + fromUserId: currentUserId, + targetUserId: remoteUserId, + offer: { + type: offer.type, + sdp: offer.sdp, + }, + }); + } catch (error) { + console.error("Failed to create/send WebRTC offer", error); + } + }, + [createPeerConnection, sendSignalMessage] + ); + + const ensurePeerConnection = useCallback( + (remoteUserId: string) => { + const currentUserId = currentUserIdRef.current; + if (!currentUserId || !remoteUserId || remoteUserId === currentUserId) return; + if (!localStreamRef.current) return; + + if (!pcRefs.current.has(remoteUserId)) { + createPeerConnection(remoteUserId); + } + + if (currentUserId < remoteUserId && !initiatedOffersRef.current.has(remoteUserId)) { + initiatedOffersRef.current.add(remoteUserId); + initiateOffer(remoteUserId); + } + }, + [createPeerConnection, initiateOffer] + ); + + useEffect(() => { + remoteStreams.forEach((stream, userId) => { + attachStreamToVideo(userId, stream); + }); + }, [remoteStreams, attachStreamToVideo]); + + useEffect(() => { + if (!hasDeterminedTeam || !localStreamRef.current) return; + const currentUserId = currentUserIdRef.current; + if (!currentUserId) return; + + const uniqueMemberIds = Array.from( + new Set( + [...myTeamMembers, ...opponentTeamMembers] + .map((member) => member.userId) + .filter((id): id is string => Boolean(id)) + ) + ); + + uniqueMemberIds.forEach((memberId) => { + ensurePeerConnection(memberId); + }); + + pcRefs.current.forEach((_pc, userId) => { + if (!uniqueMemberIds.includes(userId)) { + closePeerConnection(userId); + } + }); + }, [ + myTeamMembers, + opponentTeamMembers, + hasDeterminedTeam, + ensurePeerConnection, + closePeerConnection, + localStream, + ]); // Ordered list of debate phases const phaseOrder: DebatePhase[] = [ DebatePhase.OpeningFor, @@ -275,8 +566,16 @@ const TeamDebateRoom: React.FC = () => { const token = getAuthToken(); // Allow proceeding if we have a token, even if user isn't fully loaded yet - // The debate API will use the token for authentication - if (!debateId || (!token && !currentUser?.id)) { + // The debate API will use the token for authentication + if (!debateId || (!token && !currentUser?.id)) { + console.log("[TeamDebateRoom] Waiting for user/token...", { + debateId, + userId: currentUser?.id, + hasToken: !!token, + isUserLoading, + userFromAtom: user?.id, + userFromHook: userFromHook?.id + }); return; } @@ -295,6 +594,16 @@ const TeamDebateRoom: React.FC = () => { (member: TeamMember) => member.userId === userId ); + console.log("[TeamDebateRoom] Determining user team:", { + userId: userId, + currentUser: currentUser?.id, + userFromAtom: user?.id, + userTeam1, + userTeam2, + team1Members: debateData.team1Members?.map((m: TeamMember) => m.userId), + team2Members: debateData.team2Members?.map((m: TeamMember) => m.userId), + }); + if (userTeam1) { setIsTeam1(true); setMyTeamId(debateData.team1Id); @@ -306,6 +615,7 @@ const TeamDebateRoom: React.FC = () => { const team2Stance = debateData.team2Stance === "for" ? "for" : "against"; setLocalRole(team1Stance); setPeerRole(team2Stance); + console.log("[TeamDebateRoom] User is Team1. My Team:", debateData.team1Name, "Stance:", team1Stance, "Opponent:", debateData.team2Name, "Stance:", team2Stance); } else if (userTeam2) { setIsTeam1(false); setMyTeamId(debateData.team2Id); @@ -317,10 +627,16 @@ const TeamDebateRoom: React.FC = () => { const team2Stance = debateData.team2Stance === "for" ? "for" : "against"; setLocalRole(team2Stance); setPeerRole(team1Stance); + console.log("[TeamDebateRoom] User is Team2. My Team:", debateData.team2Name, "Stance:", team2Stance, "Opponent:", debateData.team1Name, "Stance:", team1Stance); + } else { + console.error("[TeamDebateRoom] ERROR: User is not in either team!"); } + setHasDeterminedTeam(true); setIsLoading(false); } catch (error) { + console.error("Failed to fetch debate:", error); + setHasDeterminedTeam(true); setIsLoading(false); } }; @@ -405,19 +721,53 @@ const TeamDebateRoom: React.FC = () => { useEffect(() => { const token = getAuthToken(); - if (!token || !debateId) { - console.debug('Skipping team debate websocket setup', { + if (!token || !debateId || !hasDeterminedTeam) { + console.log("[TeamDebateRoom] Waiting for prerequisites before connecting WebSocket...", { hasToken: !!token, debateId, + hasDeterminedTeam, }); return; } - console.debug('Opening team debate websocket', { + console.log("[TeamDebateRoom] Initializing WebSocket connection...", { debateId, - userId: currentUser?.id, + userId: currentUserIdRef.current, + hasDeterminedTeam, }); + let cancelled = false; + + const ensureMediaStream = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: 1280, height: 720 }, + audio: true, + }); + + if (cancelled) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + localStreamRef.current = stream; + setLocalStream(stream); + + const localVideo = localVideoRefs.current.get( + currentUserIdRef.current || "" + ); + if (localVideo) { + localVideo.srcObject = stream; + } + } catch (err) { + if (cancelled) return; + setMediaError( + "Failed to access camera/microphone. Please check permissions." + ); + console.error("Media error:", err); + } + }; + const wsUrl = new URL("/ws/team", BASE_URL); wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; wsUrl.searchParams.set("debateId", debateId); @@ -427,12 +777,20 @@ const TeamDebateRoom: React.FC = () => { wsRef.current = ws; ws.onopen = () => { + if (cancelled) { + ws.close(); + return; + } + + console.log("Team debate WebSocket connected"); ws.send(JSON.stringify({ type: "join" })); - getMedia(); + ensureMediaStream(); }; ws.onmessage = async (event) => { const data: WSMessage = JSON.parse(event.data); + console.log("Received WebSocket message:", data); + const amTeam1 = isTeam1Ref.current; const currentMyTeamId = myTeamIdRef.current; const currentUserId = currentUserIdRef.current; @@ -447,6 +805,7 @@ const TeamDebateRoom: React.FC = () => { const backendPhase = data.phase as DebatePhase; // Don't allow stateSync to reset phase back to Setup if debate has started if (debateStartedRef.current && backendPhase === DebatePhase.Setup) { + console.log('⚠️ stateSync tried to reset phase to Setup, but debate has started - ignoring'); } else { console.log( `stateSync: updating phase from ${debatePhaseRef.current} to ${backendPhase}` @@ -600,9 +959,11 @@ const TeamDebateRoom: React.FC = () => { if (isFromMyTeam) { // This role selection is from my team setLocalRole(data.role as DebateRole); + console.log(`Role selection from my team (${messageTeamId}): ${data.role}`); } else { // This role selection is from opponent team setPeerRole(data.role as DebateRole); + console.log(`Role selection from opponent team (${messageTeamId}): ${data.role}`); } } break; @@ -610,6 +971,7 @@ const TeamDebateRoom: React.FC = () => { case "countdownStart": { // Backend is starting countdown - show it to all users const countdownValue = (data as any).countdown || 3; + console.log('✓✓✓ COUNTDOWN STARTED FROM BACKEND:', countdownValue); setCountdown(countdownValue); // Hide setup popup when countdown starts setShowSetupPopup(false); @@ -618,7 +980,19 @@ const TeamDebateRoom: React.FC = () => { case "checkStart": // Ignore checkStart messages from backend (we shouldn't receive them) // This is sent by frontend to backend, not the other way around + console.log('Received checkStart message (ignoring - this is from us)'); break; + case "ready": { + console.log("=== READY MESSAGE RECEIVED ==="); + console.log("Received ready message:", data); + console.log("Current user:", currentUserId); + console.log("Message userId:", data.userId); + console.log("Message teamId:", data.teamId); + console.log("Message assignedToTeam:", (data as any).assignedToTeam); + console.log("isTeam1:", amTeam1); + console.log("myTeamId:", myTeamId); + console.log("Team1Ready:", data.team1Ready, "Team2Ready:", data.team2Ready); + console.log("Team1MembersCount:", data.team1MembersCount, "Team2MembersCount:", data.team2MembersCount); case "ready": { console.log("=== READY MESSAGE RECEIVED ==="); console.log("Received ready message:", data); @@ -650,12 +1024,11 @@ const TeamDebateRoom: React.FC = () => { if (data.userId === currentUserId && data.ready !== undefined) { // Verify team assignment matches if (assignedTeam && assignedTeam !== (amTeam1 ? "Team1" : "Team2")) { - console.error( - `Ready status assigned to unexpected team`, - { userId: data.userId, expected: amTeam1 ? "Team1" : "Team2", assignedTeam } - ); + console.error(`❌ CRITICAL ERROR: Ready status assigned to wrong team! User ${data.userId} is ${amTeam1 ? "Team1" : "Team2"} but assigned to ${assignedTeam}`); } else if (messageTeamId && expectedTeamId && messageTeamId !== expectedTeamId) { + console.error(`❌ WARNING: TeamId mismatch! Expected ${expectedTeamId}, got ${messageTeamId}`); } else { + console.log(`✓ Updating localReady to ${data.ready} for user ${data.userId}`); setLocalReady(data.ready); } } @@ -667,9 +1040,11 @@ const TeamDebateRoom: React.FC = () => { // Update team ready counts - these are the ACTUAL counts from backend if (data.team1Ready !== undefined) { + console.log(`Updating team1ReadyCount to ${data.team1Ready}`); setTeam1ReadyCount(data.team1Ready); } if (data.team2Ready !== undefined) { + console.log(`Updating team2ReadyCount to ${data.team2Ready}`); setTeam2ReadyCount(data.team2Ready); } // CRITICAL: Update member counts from ready message @@ -678,12 +1053,17 @@ const TeamDebateRoom: React.FC = () => { const team2Count = data.team2MembersCount ?? (data as any).team2MembersCount; if (team1Count !== undefined && team1Count !== null) { + console.log(`✓ Updating team1MembersCount to ${team1Count}`); setTeam1MembersCount(team1Count); } else { + console.warn(`⚠️ team1MembersCount is undefined in ready message. Raw data:`, data); + console.warn(`⚠️ Full message keys:`, Object.keys(data)); } if (team2Count !== undefined && team2Count !== null) { + console.log(`✓ Updating team2MembersCount to ${team2Count}`); setTeam2MembersCount(team2Count); } else { + console.warn(`⚠️ team2MembersCount is undefined in ready message. Raw data:`, data); } // Display what we're showing to the user @@ -702,17 +1082,27 @@ const TeamDebateRoom: React.FC = () => { const oppTeamTotal = amTeam1 ? (data.team2MembersCount ?? dataAny.team2MembersCount) : (data.team1MembersCount ?? dataAny.team1MembersCount); + + console.log(`[Display] isTeam1=${amTeam1}, myTeamName=${myTeamName}`); + console.log(`[Display] My Team (${myTeamName}) Ready: ${myTeamReadyCount}/${myTeamTotal}`); + console.log(`[Display] Opponent Team (${opponentTeamName}) Ready: ${oppReadyCount}/${oppTeamTotal}`); + console.log(`[Display] Backend counts - Team1Ready=${data.team1Ready}, Team2Ready=${data.team2Ready}`); + console.log(`[Display] Raw data - team1MembersCount=${data.team1MembersCount}, team2MembersCount=${data.team2MembersCount}`); + console.log(`[Display] Full ready message data:`, JSON.stringify(data)); + // Validation: Ensure we're showing the right team if (data.userId === currentUserId && assignedTeam) { const expectedTeamForUser = amTeam1 ? "Team1" : "Team2"; if (assignedTeam !== expectedTeamForUser) { + console.error(`❌ CRITICAL: User ${currentUserId} is ${amTeam1 ? "Team1" : "Team2"} but ready assigned to ${assignedTeam}!`); } else { + console.log(`✓ Validation passed: User is ${expectedTeamForUser} and ready assigned to ${assignedTeam}`); } } - - // Update peer ready status (whether all opponent team members are ready) + // Update peer ready status (whether all opponent team members are ready) const allOppReady = oppReadyCount === oppTeamTotal && oppTeamTotal > 0; setPeerReady(allOppReady); + console.log("=== END READY MESSAGE ==="); break; } case "phaseChange": { @@ -731,9 +1121,12 @@ const TeamDebateRoom: React.FC = () => { debateStartedRef.current = true; // Mark debate as started - prevent popup from reopening setShowSetupPopup(false); setCountdown(null); + console.log('✓ Debate phase changed to:', newPhase, '- closing setup popup permanently'); } else { + console.log('⚠️ Phase change to Setup - debate not started'); } } else { + console.warn('⚠️ Phase change message received but phase is undefined:', data); } break; } @@ -777,30 +1170,134 @@ const TeamDebateRoom: React.FC = () => { break; } case "offer": - // Handle WebRTC offer + if ( + data.targetUserId !== currentUserId || + !data.fromUserId || + !data.offer + ) { + break; + } + { + let pc = pcRefs.current.get(data.fromUserId); + if (!pc) { + pc = createPeerConnection(data.fromUserId); + } + if (!pc) break; + try { + await pc.setRemoteDescription( + new RTCSessionDescription(data.offer) + ); + await flushPendingCandidates(data.fromUserId); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + if (currentUserId) { + sendSignalMessage({ + type: "answer", + fromUserId: currentUserId, + targetUserId: data.fromUserId, + answer: { + type: answer.type, + sdp: answer.sdp, + }, + }); + } + } catch (error) { + console.error("Failed to process WebRTC offer", error); + } + } break; case "answer": - // Handle WebRTC answer + if ( + data.targetUserId !== currentUserId || + !data.fromUserId || + !data.answer + ) { + break; + } + { + const pc = pcRefs.current.get(data.fromUserId); + if (!pc) break; + try { + await pc.setRemoteDescription( + new RTCSessionDescription(data.answer) + ); + await flushPendingCandidates(data.fromUserId); + } catch (error) { + console.error("Failed to process WebRTC answer", error); + } + } break; case "candidate": - // Handle WebRTC ICE candidate + if ( + data.targetUserId !== currentUserId || + !data.fromUserId || + !data.candidate + ) { + break; + } + { + let pc = pcRefs.current.get(data.fromUserId); + if (!pc) { + pc = createPeerConnection(data.fromUserId); + } + if (!pc) break; + + const candidateInit = data.candidate as RTCIceCandidateInit; + try { + if (pc.remoteDescription) { + await pc.addIceCandidate(new RTCIceCandidate(candidateInit)); + } else { + const queue = + pendingCandidatesRef.current.get(data.fromUserId) || []; + queue.push(candidateInit); + pendingCandidatesRef.current.set(data.fromUserId, queue); + } + } catch (error) { + console.error("Failed to add ICE candidate", error); + } + } + break; + case "leave": + if (data.userId) { + closePeerConnection(data.userId); + } break; } }; + ws.onerror = (err) => console.error("WebSocket error:", err); + ws.onclose = () => console.log("WebSocket closed"); + return () => { + cancelled = true; if (localStreamRef.current) { localStreamRef.current.getTracks().forEach((track) => track.stop()); } if (wsRef.current) { wsRef.current.close(); + wsRef.current = null; } pcRefs.current.forEach((pc) => pc.close()); }; - }, [debateId, getMedia, currentUser?.id]); + }, [ + debateId, + hasDeterminedTeam, + createPeerConnection, + flushPendingCandidates, + sendSignalMessage, + closePeerConnection, + myTeamId, + currentUser?.id, + ]); // Initialize Speech Recognition useEffect(() => { + if (speechRecognitionDisabled) { + recognitionRef.current = null; + return; + } + const initializeSpeechRecognition = () => { if ( "SpeechRecognition" in window || @@ -878,22 +1375,42 @@ const TeamDebateRoom: React.FC = () => { isMyTurn && debatePhase !== DebatePhase.Setup && debatePhase !== DebatePhase.Finished + && !speechRecognitionDisabled ) { setTimeout(() => { if (recognitionRef.current) { try { recognitionRef.current.start(); } catch (error) { + console.error("Error restarting speech recognition:", error); } } }, 100); } }; - recognition.onerror = () => { + recognition.onerror = (event: Event) => { setIsListening(false); + console.error("Speech recognition error:", event); + + const errorEvent = event as Event & { error?: string }; + if (errorEvent.error === "not-allowed") { + setSpeechRecognitionDisabled(true); + setSpeechError( + "Speech recognition is blocked. Please grant microphone permission or disable speech-to-text." + ); + try { + recognition.stop(); + } catch { + // ignore + } + recognitionRef.current = null; + } else { + setSpeechError("Speech recognition error occurred."); + } }; } else { + setSpeechRecognitionDisabled(true); setSpeechError("Speech recognition not supported in this browser"); } }; @@ -905,12 +1422,13 @@ const TeamDebateRoom: React.FC = () => { recognitionRef.current.stop(); } }; - }, [debatePhase, isMyTurn, currentUser?.id, currentUser?.displayName]); + }, [debatePhase, isMyTurn, currentUser?.id, currentUser?.displayName, speechRecognitionDisabled]); // Start/stop speech recognition based on turn const startSpeechRecognition = useCallback(() => { if ( !recognitionRef.current || + speechRecognitionDisabled || isListening || debatePhase === DebatePhase.Setup || debatePhase === DebatePhase.Finished @@ -921,14 +1439,16 @@ const TeamDebateRoom: React.FC = () => { try { recognitionRef.current.start(); } catch (error) { + console.error("Error starting speech recognition:", error); } - }, [isListening, debatePhase]); + }, [isListening, debatePhase, speechRecognitionDisabled]); const stopSpeechRecognition = useCallback(() => { if (recognitionRef.current && isListening) { try { recognitionRef.current.stop(); } catch (error) { + console.error("Error stopping speech recognition:", error); } } }, [isListening]); @@ -1050,6 +1570,7 @@ const TeamDebateRoom: React.FC = () => { } } } catch (error) { + console.error("Failed to submit transcripts:", error); setPopup({ show: false, message: "Error occurred while judging. Please try again.", @@ -1080,11 +1601,13 @@ const TeamDebateRoom: React.FC = () => { const toggleReady = () => { const newReadyState = !localReady; setLocalReady(newReadyState); + console.log(`Toggling ready to ${newReadyState} for user ${currentUser?.id}`); if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( JSON.stringify({ type: "ready", ready: newReadyState }) ); } else { + console.error("WebSocket is not open, cannot send ready message"); } }; @@ -1108,8 +1631,8 @@ const TeamDebateRoom: React.FC = () => { const allMyTeamReady = myTeamReadyCount === myTeamTotal && myTeamTotal > 0; const allOpponentReady = oppTeamReadyCount === oppTeamTotal && oppTeamTotal > 0; const allReady = allMyTeamReady && allOpponentReady; - - console.log("Setup popup state:", { + + console.log('Ready check:', { myTeamReadyCount, myTeamTotal, allMyTeamReady, @@ -1119,9 +1642,9 @@ const TeamDebateRoom: React.FC = () => { allReady, localReady, peerReady, - debatePhase, + debatePhase }); - + // CRITICAL: Check if debate has started first - if so, NEVER show popup again if (debateStartedRef.current || debatePhase !== DebatePhase.Setup) { // Debate has started - don't show popup EVER @@ -1138,6 +1661,7 @@ const TeamDebateRoom: React.FC = () => { // All ready - close popup and start countdown setShowSetupPopup(false); if (countdown === null) { + console.log('🚀 All teams ready! Starting countdown...'); setCountdown(3); // Also notify backend (for synchronization) @@ -1145,6 +1669,7 @@ const TeamDebateRoom: React.FC = () => { try { wsRef.current.send(JSON.stringify({ type: "checkStart" })); } catch (error) { + console.error('Failed to send checkStart:', error); } } } @@ -1164,6 +1689,8 @@ const TeamDebateRoom: React.FC = () => { return () => clearTimeout(timer); } else if (countdown === 0) { // Countdown finished - start the debate by transitioning to OpeningFor + console.log('✓✓✓ COUNTDOWN FINISHED! Starting debate at OpeningFor phase'); + console.log('Current phase before change:', debatePhase); // Mark debate as started FIRST to prevent popup from reopening debateStartedRef.current = true; @@ -1173,13 +1700,16 @@ const TeamDebateRoom: React.FC = () => { // Change phase to OpeningFor const newPhase = DebatePhase.OpeningFor; + console.log('Setting debate phase to:', newPhase); setDebatePhase(newPhase); // Send phase change to backend if (wsRef.current?.readyState === WebSocket.OPEN) { const phaseChangeMessage = JSON.stringify({ type: "phaseChange", phase: DebatePhase.OpeningFor }); + console.log('Sending phase change to backend:', phaseChangeMessage); wsRef.current.send(phaseChangeMessage); } else { + console.error('❌ WebSocket not open, cannot send phase change'); } // Note: debatePhase state won't update immediately due to React batching @@ -1210,13 +1740,13 @@ const TeamDebateRoom: React.FC = () => { // Debug: Log user state for troubleshooting useEffect(() => { if (hasAuthToken && !currentUser?.id) { - console.log("Auth state while loading user:", { + console.log("[TeamDebateRoom] Has token but no user ID:", { hasToken: hasAuthToken, currentUser, userFromAtom: user, userFromHook, isUserLoading, - isAuthenticated, + isAuthenticated }); } }, [hasAuthToken, currentUser, user, userFromHook, isUserLoading, isAuthenticated]); @@ -1495,7 +2025,6 @@ const TeamDebateRoom: React.FC = () => { judgment={judgmentData} forRole={localRole === "for" ? "Your Team" : "Opponent Team"} againstRole={localRole === "against" ? "Your Team" : "Opponent Team"} - localRole={localRole ?? null} onClose={() => setShowJudgment(false)} /> )} @@ -1570,6 +2099,10 @@ const TeamDebateRoom: React.FC = () => { } } else { remoteVideoRefs.current.set(member.userId, el); + const existingStream = remoteStreams.get(member.userId); + if (existingStream) { + attachStreamToVideo(member.userId, existingStream); + } } } }} @@ -1661,6 +2194,10 @@ const TeamDebateRoom: React.FC = () => { ref={(el) => { if (el) { remoteVideoRefs.current.set(member.userId, el); + const existingStream = remoteStreams.get(member.userId); + if (existingStream) { + attachStreamToVideo(member.userId, existingStream); + } } }} autoPlay @@ -1684,21 +2221,22 @@ const TeamDebateRoom: React.FC = () => {
)} - {/* Media Error Display */} + {/* Media / Speech Error Display */} {mediaError && (

{mediaError}

)} - {speechError && ( -

- {speechError} -

- )} + {speechError && ( +

+ {speechError} +

+ )}