diff --git a/api/admin.go b/api/admin.go index 6580ad2b..f49e4440 100644 --- a/api/admin.go +++ b/api/admin.go @@ -70,3 +70,55 @@ func banUserHandler(c *gin.Context) { }) return } + +func banTeamHandler(c *gin.Context) { + action := c.Param("action") + teamId := c.Param("id") + + // Validate action + if (action != core.TEAM_STATUS["ban"]) && (action != core.TEAM_STATUS["unban"]) { + c.JSON(http.StatusBadRequest, HTTPPlainResp{ + Message: "Action not provided or invalid action format", + }) + return + } + + var teamState uint + if action == core.TEAM_STATUS["ban"] { + teamState = 1 + } else if action == core.TEAM_STATUS["unban"] { + teamState = 0 + } + + // Convert teamId to integer + parsedTeamId, err := strconv.Atoi(teamId) + if err != nil { + c.JSON(http.StatusBadRequest, HTTPPlainResp{ + Message: "Team Id format invalid", + }) + return + } + + // Fetch the team from the database + team, err := database.QueryTeamById(uint(parsedTeamId)) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPPlainResp{ + Message: "DATABASE ERROR while processing the request.", + }) + return + } + + // Update the team's status + err = database.UpdateTeam(&team, map[string]interface{}{"Status": teamState}) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPPlainResp{ + Message: "DATABASE ERROR while processing the request.", + }) + return + } + + c.JSON(http.StatusOK, HTTPPlainResp{ + Message: fmt.Sprintf("Successfully %sned the team with id %s", action, teamId), + }) + +} diff --git a/api/info.go b/api/info.go index 2c6cc3c3..fd20e7f9 100644 --- a/api/info.go +++ b/api/info.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" @@ -471,6 +472,20 @@ func userInfoHandler(c *gin.Context) { return } + team, err := database.QueryTeamByUserId(parsedUserId) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while processing the request.", + }) + return + } + + var teamInfo string + if team != nil { + teamInfo = team.Name + } + resp = UserResp{ Username: user.Username, Id: user.ID, @@ -480,6 +495,7 @@ func userInfoHandler(c *gin.Context) { Rank: rank, Email: user.Email, Challenges: userChallenges, + TeamName: teamInfo, } c.JSON(http.StatusOK, resp) return @@ -507,7 +523,6 @@ func getAllUsersInfoHandler(c *gin.Context) { availableUsers := make([]UsersResp, len(users)) if len(users) > 0 { for index, user := range users { - parsedUserId := uint(user.ID) var rank int64 @@ -525,6 +540,21 @@ func getAllUsersInfoHandler(c *gin.Context) { return } + // Get team info + team, err := database.QueryTeamByUserId(parsedUserId) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while processing the request.", + }) + return + } + + var teamName string + if team != nil { + teamName = team.Name + } + availableUsers[index] = UsersResp{ Username: user.Username, Id: user.ID, @@ -533,6 +563,7 @@ func getAllUsersInfoHandler(c *gin.Context) { Score: user.Score, Email: user.Email, Rank: rank, + TeamName: teamName, } } @@ -790,3 +821,281 @@ func tagHandler(c *gin.Context) { }) return } + +type TeamInfoResp struct { + ID uint `json:"id"` + Name string `json:"name"` + Score uint `json:"score"` + Members []UserResp `json:"members"` + Solves []ChallengeSolveResp `json:"solves"` + CreatedAt time.Time `json:"created_at"` +} + +// Returns info for all teams +// @Summary Returns info for all teams +// @Description Returns a list of all teams with their info +// @Tags info +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer" +// @Success 200 {array} api.TeamInfoResp +// @Failure 500 {object} api.HTTPErrorResp +// @Router /api/info/teams [get] +func teamsInfoHandler(c *gin.Context) { + teams, err := database.GetAllTeams() + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while getting teams.", + }) + return + } + + teamInfos := make([]TeamInfoResp, len(teams)) + for i, team := range teams { + // Get team members + members, err := database.GetTeamMembers(team.ID) + if err != nil { + log.Error(err) + continue + } + + // Get member info + memberInfo := make([]UserResp, len(members)) + for j, member := range members { + rank, err := database.GetUserRank(member.ID, member.Score, member.UpdatedAt) + if err != nil { + log.Error(err) + rank = 0 + } + + // Get member's solved challenges + challenges, err := database.GetRelatedChallenges(&member) + if err != nil { + log.Error(err) + challenges = []database.Challenge{} + } + + // Convert challenges to challenge info + memberChallenges := make([]ChallengeSolveResp, len(challenges)) + for k, challenge := range challenges { + challengeTags := make([]string, len(challenge.Tags)) + for i, tag := range challenge.Tags { + challengeTags[i] = tag.TagName + } + + memberChallenges[k] = ChallengeSolveResp{ + Id: challenge.ID, + Name: challenge.Name, + Tags: challengeTags, + Category: challenge.Type, + SolvedAt: challenge.CreatedAt, + Points: challenge.Points, + } + } + + memberInfo[j] = UserResp{ + Username: member.Username, + Id: member.ID, + Role: member.Role, + Status: member.Status, + Score: member.Score, + Rank: rank, + Email: member.Email, + Challenges: memberChallenges, + } + } + + // Get team solves + solves := []ChallengeSolveResp{} + for _, member := range members { + challenges, err := database.GetRelatedChallenges(&member) + if err != nil { + log.Error(err) + continue + } + + for _, challenge := range challenges { + challengeTags := make([]string, len(challenge.Tags)) + for i, tag := range challenge.Tags { + challengeTags[i] = tag.TagName + } + + solve := ChallengeSolveResp{ + Id: challenge.ID, + Name: challenge.Name, + Tags: challengeTags, + Category: challenge.Type, + SolvedAt: challenge.CreatedAt, + Points: challenge.Points, + } + solves = append(solves, solve) + } + } + + // Remove duplicate solves (same challenge solved by multiple team members) + uniqueSolves := make(map[uint]ChallengeSolveResp) + for _, solve := range solves { + uniqueSolves[solve.Id] = solve + } + + finalSolves := make([]ChallengeSolveResp, 0, len(uniqueSolves)) + for _, solve := range uniqueSolves { + finalSolves = append(finalSolves, solve) + } + + // Sort solves by time + sort.Slice(finalSolves, func(i, j int) bool { + return finalSolves[i].SolvedAt.Before(finalSolves[j].SolvedAt) + }) + + teamInfos[i] = TeamInfoResp{ + ID: team.ID, + Name: team.Name, + Score: team.Score, + Members: memberInfo, + Solves: finalSolves, + CreatedAt: team.CreatedAt, + } + } + + c.JSON(http.StatusOK, teamInfos) +} + +// Returns team info by name +// @Summary Returns team info by name +// @Description Returns detailed info for a specific team +// @Tags info +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer" +// @Param name path string true "Team name" +// @Success 200 {object} api.TeamInfoResp +// @Failure 400 {object} api.HTTPErrorResp +// @Failure 500 {object} api.HTTPErrorResp +// @Router /api/info/teams/{name} [get] +func teamInfoHandler(c *gin.Context) { + teamName := c.Param("name") + if teamName == "" { + c.JSON(http.StatusBadRequest, HTTPErrorResp{ + Error: "Team name cannot be empty", + }) + return + } + + team, err := database.QueryFirstTeamEntry("name", teamName) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while processing the request.", + }) + return + } + + // Get team members + members, err := database.GetTeamMembers(team.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while getting team members.", + }) + return + } + + // Get member info + memberInfo := make([]UserResp, len(members)) + for i, member := range members { + rank, err := database.GetUserRank(member.ID, member.Score, member.UpdatedAt) + if err != nil { + log.Error(err) + rank = 0 + } + + // Get member's solved challenges + challenges, err := database.GetRelatedChallenges(&member) + if err != nil { + log.Error(err) + challenges = []database.Challenge{} + } + + // Convert challenges to challenge info + memberChallenges := make([]ChallengeSolveResp, len(challenges)) + for k, challenge := range challenges { + challengeTags := make([]string, len(challenge.Tags)) + for i, tag := range challenge.Tags { + challengeTags[i] = tag.TagName + } + + memberChallenges[k] = ChallengeSolveResp{ + Id: challenge.ID, + Name: challenge.Name, + Tags: challengeTags, + Category: challenge.Type, + SolvedAt: challenge.CreatedAt, + Points: challenge.Points, + } + } + + memberInfo[i] = UserResp{ + Username: member.Username, + Id: member.ID, + Role: member.Role, + Status: member.Status, + Score: member.Score, + Rank: rank, + Email: member.Email, + Challenges: memberChallenges, + } + } + + // Get team solves + solves := []ChallengeSolveResp{} + for _, member := range members { + challenges, err := database.GetRelatedChallenges(&member) + if err != nil { + log.Error(err) + continue + } + + for _, challenge := range challenges { + challengeTags := make([]string, len(challenge.Tags)) + for i, tag := range challenge.Tags { + challengeTags[i] = tag.TagName + } + + solve := ChallengeSolveResp{ + Id: challenge.ID, + Name: challenge.Name, + Tags: challengeTags, + Category: challenge.Type, + SolvedAt: challenge.CreatedAt, + Points: challenge.Points, + } + solves = append(solves, solve) + } + } + + // Remove duplicate solves (same challenge solved by multiple team members) + uniqueSolves := make(map[uint]ChallengeSolveResp) + for _, solve := range solves { + uniqueSolves[solve.Id] = solve + } + + finalSolves := make([]ChallengeSolveResp, 0, len(uniqueSolves)) + for _, solve := range uniqueSolves { + finalSolves = append(finalSolves, solve) + } + + // Sort solves by time + sort.Slice(finalSolves, func(i, j int) bool { + return finalSolves[i].SolvedAt.Before(finalSolves[j].SolvedAt) + }) + + resp := TeamInfoResp{ + ID: team.ID, + Name: team.Name, + Score: team.Score, + Members: memberInfo, + Solves: finalSolves, + CreatedAt: team.CreatedAt, + } + + c.JSON(http.StatusOK, resp) +} diff --git a/api/response.go b/api/response.go index 103c8292..0084bf6b 100644 --- a/api/response.go +++ b/api/response.go @@ -71,6 +71,7 @@ type UserResp struct { Rank int64 `json:"rank" example:"15"` Email string `json:"email" example:"fristonio@gmail.com"` Challenges []ChallengeSolveResp `json:"challenges"` + TeamName string `json:"team_name,omitempty"` } type UsersResp struct { @@ -81,6 +82,7 @@ type UsersResp struct { Score uint `json:"score" example:"750"` Email string `json:"email" example:"fristonio@gmail.com"` Rank int64 `json:"rank" example:"15"` + TeamName string `json:"team_name,omitempty"` } type ChallengeSolveResp struct { diff --git a/api/router.go b/api/router.go index 13fb5732..dde17b9b 100644 --- a/api/router.go +++ b/api/router.go @@ -81,6 +81,8 @@ func initGinRouter() *gin.Engine { infoGroup.GET("/users", getAllUsersInfoHandler) infoGroup.GET("/submissions", submissionsHandler) infoGroup.GET("/tags", tagHandler) + infoGroup.GET("/teams", teamsInfoHandler) + infoGroup.GET("/teams/:name", teamInfoHandler) } // Notification route group @@ -113,8 +115,25 @@ func initGinRouter() *gin.Engine { adminPanelGroup := apiGroup.Group("/admin", adminAuthorize) { adminPanelGroup.POST("/users/:action/:id", banUserHandler) + adminPanelGroup.POST("/teams/:action/:id", banTeamHandler) adminPanelGroup.GET("/statistics", getUsersStatisticsHandler) } + teamGroup := apiGroup.Group("/team") + { + teamGroup.POST("/create", createTeamHandler) + teamGroup.GET("/scoreboard", scoreboardHandler) + teamGroup.POST("/join/:code", joinTeamHandler) + teamGroup.GET("/members/:id", getTeamMembersHandler) + teamGroup.POST("/leave", leaveTeamHandler) + + // Captain-only routes + captainGroup := teamGroup.Group("/", teamCaptainAuthorize) + { + captainGroup.POST("/invite", generateInviteLinkHandler) + captainGroup.POST("/remove", removeMemberHandler) + captainGroup.POST("/transfer", transferCaptaincyHandler) + } + } } router.NoRoute(func(c *gin.Context) { diff --git a/api/submit.go b/api/submit.go index fc721ab2..e2d9891f 100644 --- a/api/submit.go +++ b/api/submit.go @@ -112,25 +112,35 @@ func submitFlagHandler(c *gin.Context) { }) return } - if challenge.Flag != flag { - c.JSON(http.StatusOK, FlagSubmitResp{ - Message: "Your flag is incorrect", - Success: false, + + // Check if user is in a team + if user.TeamID == 0 { + c.JSON(http.StatusBadRequest, HTTPErrorResp{ + Error: "You must be in a team to submit a challenge.", }) return } - solved, err := database.CheckPreviousSubmissions(user.ID, challenge.ID) + // Check if user's team has already solved it + solved, err := database.CheckTeamSolvedChallenge(user.TeamID, challenge.ID) if err != nil { c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: "DATABASE ERROR while processing the request.", }) return } - if solved { c.JSON(http.StatusOK, FlagSubmitResp{ - Message: "Challenge has already been solved.", + Message: "Challenge has already been solved by your team.", + Success: false, + }) + return + } + + // Validate the flag after team checks + if challenge.Flag != flag { + c.JSON(http.StatusOK, FlagSubmitResp{ + Message: "Your flag is incorrect", Success: false, }) return @@ -160,7 +170,21 @@ func submitFlagHandler(c *gin.Context) { } } - err = database.UpdateUser(&user, map[string]interface{}{"Score": user.Score + challengePoints}) + // Save the solve + UserChallengesEntry := database.UserChallenges{ + CreatedAt: time.Now(), + UserID: user.ID, + ChallengeID: challenge.ID, + } + if err := database.SaveFlagSubmission(&UserChallengesEntry); err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while processing the request.", + }) + return + } + + // Update team score + team, err := database.GetTeamByID(user.TeamID) if err != nil { c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: "DATABASE ERROR while processing the request.", @@ -168,13 +192,18 @@ func submitFlagHandler(c *gin.Context) { return } - UserChallengesEntry := database.UserChallenges{ - CreatedAt: time.Time{}, - UserID: user.ID, - ChallengeID: challenge.ID, + // Update team with new points + if err := database.UpdateTeam(&team, map[string]interface{}{ + "Score": team.Score + challengePoints, + }); err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while processing the request.", + }) + return } - err = database.SaveFlagSubmission(&UserChallengesEntry) + // Update user score + err = database.UpdateUser(&user, map[string]interface{}{"Score": user.Score + challengePoints}) if err != nil { c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: "DATABASE ERROR while processing the request.", @@ -202,17 +231,44 @@ func dynamicScore(maxPoints, minPoints, solvers uint) uint { // updatePointsOfSolvers updates the points of solvers, whenever points of challenge changes func updatePointsOfSolvers(submissions []database.UserChallenges, newChallengePointsAfterSolve, oldChallengePointsBeforeSolve uint) error { - for _, submission := range submissions { - user, err := database.QueryUserById(submission.UserID) - if err != nil { - return err - } - if user.Role == "contestant" { - err = database.UpdateUser(&user, map[string]interface{}{"Score": user.Score + (newChallengePointsAfterSolve - oldChallengePointsBeforeSolve)}) - if err != nil { - return err - } - } - } - return nil + // Track which teams we've updated to avoid duplicate updates + updatedTeams := make(map[uint]bool) + + for _, submission := range submissions { + user, err := database.QueryUserById(submission.UserID) + if err != nil { + return err + } + + if user.Role == "contestant" { + // Update user's score + err = database.UpdateUser(&user, map[string]interface{}{ + "Score": user.Score + (newChallengePointsAfterSolve - oldChallengePointsBeforeSolve), + }) + if err != nil { + return err + } + + // Update team's score if user is in a team and we haven't updated this team yet + if user.TeamID != 0 && !updatedTeams[user.TeamID] { + team, err := database.GetTeamByID(user.TeamID) + if err != nil { + log.Error(err) + continue + } + + err = database.UpdateTeam(&team, map[string]interface{}{ + "Score": team.Score + (newChallengePointsAfterSolve - oldChallengePointsBeforeSolve), + }) + if err != nil { + log.Error(err) + continue + } + + // Mark this team as updated + updatedTeams[user.TeamID] = true + } + } + } + return nil } diff --git a/api/team.go b/api/team.go new file mode 100644 index 00000000..385dc368 --- /dev/null +++ b/api/team.go @@ -0,0 +1,442 @@ +package api + +import ( + "fmt" + "net/http" + "sort" + "strings" + "time" + "strconv" + "github.com/gin-gonic/gin" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/core/utils" + coreUtils "github.com/sdslabs/beastv4/core/utils" +) + +type ScoreboardEntry struct { + ID uint `json:"id"` + Name string `json:"name"` + Score uint `json:"score"` +} + +type TeamMember struct { + ID uint `json:"id"` + Username string `json:"username"` + IsCaptain bool `json:"is_captain"` + Score uint `json:"score"` +} + +// getTeamMembersHandler retrieves all members of a team and their scores +func getTeamMembersHandler(c *gin.Context) { + // Get the team ID from the URL parameter + teamID := c.Param("id") + + // Convert teamID to uint + var teamIDUint uint + if _, err := fmt.Sscanf(teamID, "%d", &teamIDUint); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Invalid team_id", + }) + return + } + + // Fetch team members and their details + members, err := database.GetTeamMembers(teamIDUint) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Error fetching team members", + }) + return + } + + // Prepare response with team members and their score + var memberDetails []TeamMember + for _, member := range members { + memberDetails = append(memberDetails, TeamMember{ + ID: member.ID, + Username: member.Username, + IsCaptain: member.IsTeamCaptain, + Score: member.Score, + }) + } + + // Return the team members and their details as JSON + c.JSON(http.StatusOK, memberDetails) +} + +// scoreboardHandler returns the sorted list of teams by score +func scoreboardHandler(c *gin.Context) { + var scoreboard []ScoreboardEntry + + // Get all teams + var teams []database.Team + if err := database.Db.Find(&teams).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": fmt.Sprintf("Error fetching teams: %v", err), + }) + return + } + + // Add teams to scoreboard + for _, team := range teams { + scoreboard = append(scoreboard, ScoreboardEntry{ + ID: team.ID, + Name: team.Name, + Score: team.Score, + }) + } + + // Sort scoreboard by score in descending order + sort.Slice(scoreboard, func(i, j int) bool { + return scoreboard[i].Score > scoreboard[j].Score + }) + + // Return scoreboard as JSON + c.JSON(http.StatusOK, scoreboard) +} + +// CreateTeamHandler handles the creation of a new team +func createTeamHandler(c *gin.Context) { + name := strings.TrimSpace(c.PostForm("name")) + + // Validate team name + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Team name cannot be empty.", + }) + return + } + + // Get the username from the Authorization header + username, err := coreUtils.GetUser(c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "message": "Unauthorized access. Please provide a valid token.", + }) + return + } + + // Query the user by username + user, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "message": "User not found.", + }) + return + } + + // Check if user is already in a team + if user.TeamID != 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "You are already in a team.", + }) + return + } + + // Check if the team name already exists + existingTeam, err := database.QueryTeamByName(name) + if err == nil && existingTeam != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "A team with this name already exists.", + }) + return + } + + // Create the team + team := database.Team{ + Name: name, + Status: 0, // Active + Score: 0, + InviteCode: "", + InviteExpiry: time.Time{}, // Zero time + } + + if err := database.CreateTeamEntry(&team); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Failed to create team.", + }) + return + } + + // Update user to be the team captain and include team data + user.TeamID = team.ID + user.IsTeamCaptain = true + + updateData := map[string]interface{}{ + "TeamID": team.ID, + "IsTeamCaptain": true, + "Team": &team, + } + + if err := database.UpdateUser(&user, updateData); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Failed to update user with team info.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Team created successfully.", + "team_id": team.ID, + }) +} + +func teamCaptainAuthorize(c *gin.Context) { + // Get the user from the authorization header (JWT or other) + username, err := utils.GetUser(c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "message": "Unauthorized access. Please provide a valid token.", + }) + c.Abort() // Stop further processing + return + } + + // Fetch user details from the database + user, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "message": "User not found.", + }) + c.Abort() // Stop further processing + return + } + + // Check if the user is a team captain + if !user.IsTeamCaptain { + c.JSON(http.StatusForbidden, gin.H{ + "message": "You are not the team captain, and cannot perform this action.", + }) + c.Abort() // Stop further processing + return + } + + // Proceed with the request if the user is the team captain + c.Next() +} + +func removeMemberHandler(c *gin.Context) { + // Get username from form data + username := strings.TrimSpace(c.PostForm("username")) + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Username is required.", + }) + return + } + + // Get the captain's username (captain is validated by middleware) + captainUsername, err := coreUtils.GetUser(c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "message": "Unauthorized user", + }) + return + } + + // Fetch the captain's details + captain, err := database.QueryFirstUserEntry("username", captainUsername) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "message": "Unauthorized user", + }) + return + } + + // Verify captain is actually a team captain + if !captain.IsTeamCaptain { + c.JSON(http.StatusForbidden, gin.H{ + "message": "Only team captains can remove members.", + }) + return + } + + // Fetch the user to be removed from the database by username + user, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "message": "User not found.", + }) + return + } + + // Check if user is in captain's team + if user.TeamID != captain.TeamID { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "User is not a member of your team.", + }) + return + } + + // Prevent removing self + if user.ID == captain.ID { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Team captain cannot remove themselves.", + }) + return + } + + // Remove the user from the team + err = database.RemoveUserFromTeam(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": fmt.Sprintf("Failed to remove user: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "User removed from the team successfully.", + }) +} + +// leaveTeamHandler handles a team member's request to leave their team +func leaveTeamHandler(c *gin.Context) { + // Get the current user + username, err := coreUtils.GetUser(c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "message": "Unauthorized user", + }) + return + } + + // Get user details + user, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Error fetching user details", + }) + return + } + + // Check if user is in a team + if user.TeamID == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "You are not in a team", + }) + return + } + + // Check if user is team captain + isCaptain, err := database.IsUserTeamCaptain(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Error checking team captain status", + }) + return + } + + if isCaptain { + // Get number of team members + members, err := database.GetTeamMembers(user.TeamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Error fetching team members", + }) + return + } + + if len(members) > 1 { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Team captain cannot leave while other members are in the team. Transfer captaincy first.", + }) + return + } + } + + // Leave team + if err := database.LeaveTeam(user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": fmt.Sprintf("Error leaving team: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Successfully left the team", + }) +} + +// transferCaptaincyHandler handles the transfer of team captaincy to another team member +func transferCaptaincyHandler(c *gin.Context) { + // Get the current user + username, err := coreUtils.GetUser(c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "message": "Unauthorized user", + }) + return + } + + // Get new captain's user ID from request + memberID := c.PostForm("member_id") + if memberID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "New captain ID is required", + }) + return + } + + // Parse member ID to uint + parsedMemberID, err := strconv.ParseUint(memberID, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Invalid member ID format", + }) + return + } + + // Get current user details + currentUser, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Error fetching user details", + }) + return + } + + // Verify current user is team captain + isCaptain, err := database.IsUserTeamCaptain(currentUser.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Error checking team captain status", + }) + return + } + + if !isCaptain { + c.JSON(http.StatusForbidden, gin.H{ + "message": "Only team captain can transfer captaincy", + }) + return + } + + // Verify new captain exists and is in the same team + newCaptain, err := database.QueryUserById(uint(parsedMemberID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Error fetching new captain details", + }) + return + } + + if newCaptain.TeamID != currentUser.TeamID { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "New captain must be in your team", + }) + return + } + + // Transfer captaincy + if err := database.TransferCaptaincy(currentUser.ID, uint(parsedMemberID)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": fmt.Sprintf("Error transferring captaincy: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Team captaincy transferred successfully", + }) +} diff --git a/api/team_invite.go b/api/team_invite.go new file mode 100644 index 00000000..b86e8031 --- /dev/null +++ b/api/team_invite.go @@ -0,0 +1,141 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/sdslabs/beastv4/core/config" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/core/utils" +) + +// generateInviteCode generates a random invite code +func generateInviteCode() string { + b := make([]byte, 6) + rand.Read(b) + return base64.URLEncoding.EncodeToString(b)[:8] // 8 characters is enough +} + +// generateInviteLinkHandler generates a team invite link +// @Summary Generate a team invite link +// @Tags team +// @Produce json +// @Success 200 {object} map[string]string +// @Failure 401,404 {object} HTTPErrorResp +// @Router /api/team/invite/generate [post] +func generateInviteLinkHandler(c *gin.Context) { + // Get the captain's username from the Authorization header + username, err := utils.GetUser(c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) + return + } + + // Get the captain's user info + captain, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "User not found"}) + return + } + + // Verify the user is a team captain + if !captain.IsTeamCaptain || captain.TeamID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Only team captains can generate invite links"}) + return + } + + // Generate invite code + inviteCode := generateInviteCode() + + // Store the invite code in the team's record + team := &database.Team{} + if err := database.Db.First(team, captain.TeamID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "Team not found"}) + return + } + + team.InviteCode = inviteCode + team.InviteExpiry = time.Now().Add(24 * time.Hour) // Expires in 24 hours + if err := database.Db.Save(team).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate invite"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Invite link generated successfully", + "code": inviteCode, + }) +} + +// joinTeamHandler handles joining a team with an invite code +// @Summary Join a team using invite code +// @Tags team +// @Accept json +// @Produce json +// @Param code path string true "Invite code" +// @Success 200 {object} HTTPPlainResp +// @Failure 400,404 {object} HTTPErrorResp +// @Router /api/team/join/{code} [post] +func joinTeamHandler(c *gin.Context) { + code := c.Param("code") + + // Get the user from the Authorization header + username, err := utils.GetUser(c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) + return + } + + user, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "User not found"}) + return + } + + // Check if user is already in a team + if user.TeamID != 0 { + c.JSON(http.StatusBadRequest, gin.H{"message": "You are already in a team"}) + return + } + + // Find team by invite code + team := &database.Team{} + if err := database.Db.Where("invite_code = ?", code).First(team).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "Invalid invite code"}) + return + } + + // Check if invite is expired + if team.InviteExpiry.Before(time.Now()) { + c.JSON(http.StatusBadRequest, gin.H{"message": "Invite code has expired"}) + return + } + + // Get team members count + var memberCount int64 + if err := database.Db.Model(&database.User{}).Where("team_id = ?", team.ID).Count(&memberCount).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to check team size"}) + return + } + + // Check team size against config limit + if memberCount >= int64(config.Cfg.CompetitionInfo.TeamSize) { + c.JSON(http.StatusBadRequest, gin.H{"message": "Team has reached maximum size limit"}) + return + } + + // Add user to team + updateData := map[string]interface{}{ + "TeamID": team.ID, + } + if err := database.UpdateUser(&user, updateData); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to join team"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Successfully joined team"}) +} diff --git a/core/config/config.go b/core/config/config.go index 79d8008e..703992ad 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -250,6 +250,7 @@ type CompetitionInfo struct { TimeZone string `toml:"timezone"` LogoURL string `toml:"logo_url"` DynamicScore bool `toml:"dynamic_score"` + TeamSize uint `toml:"team_size"` // Maximum number of members in a team } func UpdateCompetitionInfo(competitionInfo *CompetitionInfo) error { diff --git a/core/constants.go b/core/constants.go index 8938d2c6..1dc5f90b 100644 --- a/core/constants.go +++ b/core/constants.go @@ -195,3 +195,8 @@ var USER_STATUS = map[string]string{ "ban": "ban", "unban": "unban", } + +var TEAM_STATUS = map[string]string{ + "ban": "ban", + "unban": "unban", +} diff --git a/core/database/database.go b/core/database/database.go index 243cb78d..101c22d0 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -48,7 +48,11 @@ func init() { log.Fatalf("Cannot create related models: %s", err) } - Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &User{}, &Tag{}, &Notification{}) + if err := Db.SetupJoinTable(&Team{}, "Challenges", &TeamChallenges{}); err != nil { + log.Fatalf("Cannot create team related models: %s", err) + } + + Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &User{}, &Tag{}, &Notification{}, &Team{}, &TeamChallenges{}) users, err := QueryUserEntries("email", core.DEFAULT_USER_EMAIL) if err != nil { diff --git a/core/database/team.go b/core/database/team.go new file mode 100644 index 00000000..7dbae9a7 --- /dev/null +++ b/core/database/team.go @@ -0,0 +1,361 @@ +package database + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +type Team struct { + gorm.Model + Name string `gorm:"not null;unique"` + Score uint `gorm:"default:0"` + Members []*User `gorm:"foreignKey:TeamID"` + InviteCode string `gorm:"unique"` + InviteExpiry time.Time + Status uint `gorm:"not null;default:0"` // 0 for unbanned, 1 for banned + Challenges []*Challenge `gorm:"many2many:team_challenges;"` // Solved challenges +} + +type TeamChallenges struct { + gorm.Model + TeamID uint `gorm:"not null"` + ChallengeID uint `gorm:"not null"` + SolverID uint `gorm:"not null"` // ID of the team member who solved it + Challenge Challenge `gorm:"foreignKey:ChallengeID"` + Solver User `gorm:"foreignKey:SolverID"` +} + +// QueryTeamEntries queries all teams where the column matches the value +func QueryTeamEntries(key string, value string) ([]Team, error) { + queryKey := fmt.Sprintf("%s = ?", key) + var teams []Team + + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Where(queryKey, value).Find(&teams) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + + return teams, tx.Error +} + +// QueryFirstTeamEntry gets the first team matching the criteria +func QueryFirstTeamEntry(key string, value string) (Team, error) { + queryKey := fmt.Sprintf("%s = ?", key) + var team Team + + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Where(queryKey, value).First(&team) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return team, fmt.Errorf("team not found") + } + + return team, tx.Error +} + +// CreateTeamEntry creates a new team +func CreateTeamEntry(team *Team) error { + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Begin() + if err := tx.Create(team).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to create team: %v", err) + } + + return tx.Commit().Error +} + +// UpdateTeam updates a team entry +func UpdateTeam(team *Team, m map[string]interface{}) error { + DBMux.Lock() + defer DBMux.Unlock() + + return Db.Model(team).Updates(m).Error +} + +// GetTeamMembers gets all members of a team +func GetTeamMembers(teamID uint) ([]User, error) { + var members []User + + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Where("team_id = ?", teamID).Find(&members) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + + return members, tx.Error +} + +// GetTeamRank gets the rank of a team based on score +func GetTeamRank(teamID uint, teamScore uint, updatedAt time.Time) (int64, error) { + DBMux.Lock() + defer DBMux.Unlock() + + var rank int64 + tx := Db.Model(&Team{}). + Where("score > ? OR (score = ? AND updated_at < ?)", teamScore, teamScore, updatedAt). + Count(&rank) + + return rank + 1, tx.Error +} + +// AddUserToTeam adds a user to a team +func AddUserToTeam(userID uint, teamID uint, isCaption bool) error { + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Begin() + + // Check if user exists and isn't in a team + var user User + if err := tx.First(&user, userID).Error; err != nil { + tx.Rollback() + return fmt.Errorf("user not found") + } + + if user.TeamID != 0 { + tx.Rollback() + return fmt.Errorf("user already in a team") + } + + // Check if team exists + var team Team + if err := tx.First(&team, teamID).Error; err != nil { + tx.Rollback() + return fmt.Errorf("team not found") + } + + // Update user's team + if err := tx.Model(&user).Updates(map[string]interface{}{ + "TeamID": teamID, + "IsTeamCaptain": isCaption, + }).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to add user to team") + } + + return tx.Commit().Error +} + +// RemoveUserFromTeam removes a user from their team +func RemoveUserFromTeam(userID uint) error { + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Check if user exists and is in a team + var user User + if err := tx.First(&user, userID).Error; err != nil { + tx.Rollback() + return fmt.Errorf("user not found") + } + + if user.TeamID == 0 { + tx.Rollback() + return fmt.Errorf("user not in a team") + } + + // Remove user from team + if err := tx.Model(&user).Update("TeamID", 0).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to remove user from team: %v", err) + } + + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit transaction: %v", err) + } + + return nil +} + +// GetTeamByID gets a team by its ID +func GetTeamByID(teamID uint) (Team, error) { + var team Team + + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.First(&team, teamID) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return team, fmt.Errorf("team not found") + } + + return team, tx.Error +} + +// CheckTeamSolvedChallenge checks if a team has already solved a challenge +func CheckTeamSolvedChallenge(teamID uint, challengeID uint) (bool, error) { + DBMux.Lock() + defer DBMux.Unlock() + + // Join users and user_challenges to check if any team member has solved it + var count int64 + err := Db.Model(&User{}). + Joins("JOIN user_challenges ON users.id = user_challenges.user_id"). + Where("users.team_id = ? AND user_challenges.challenge_id = ?", teamID, challengeID). + Count(&count).Error + if err != nil { + return false, err + } + + return count > 0, nil +} + +// SaveTeamSolve records a team's solve of a challenge and updates team score +func SaveTeamSolve(teamID uint, challengeID uint, solverID uint) error { + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Begin() + + // Check if already solved + solved, err := CheckTeamSolvedChallenge(teamID, challengeID) + if err != nil { + tx.Rollback() + return err + } + if solved { + tx.Rollback() + return fmt.Errorf("challenge already solved by team") + } + + // Get challenge points + var challenge Challenge + if err := tx.First(&challenge, challengeID).Error; err != nil { + tx.Rollback() + return err + } + + // Get team to update score + var team Team + if err := tx.First(&team, teamID).Error; err != nil { + tx.Rollback() + return err + } + + // Record the solve + solve := TeamChallenges{ + TeamID: teamID, + ChallengeID: challengeID, + SolverID: solverID, + } + if err := tx.Create(&solve).Error; err != nil { + tx.Rollback() + return err + } + + // Update team score + if err := tx.Model(&team).Update("score", team.Score+challenge.Points).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +// GetTeamSolves gets all challenges solved by a team +func GetTeamSolves(teamID uint) ([]TeamChallenges, error) { + var solves []TeamChallenges + + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Where("team_id = ?", teamID). + Preload("Challenge"). + Preload("Solver"). + Find(&solves) + + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + + return solves, tx.Error +} + +// GetTeamSolveCount gets the number of challenges solved by a team +func GetTeamSolveCount(teamID uint) (int64, error) { + DBMux.Lock() + defer DBMux.Unlock() + + var count int64 + tx := Db.Model(&TeamChallenges{}).Where("team_id = ?", teamID).Count(&count) + + return count, tx.Error +} + +func QueryTeamById(teamID uint) (Team, error) { + var team Team + if err := Db.First(&team, teamID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return Team{}, errors.New("team not found") + } + return Team{}, err + } + return team, nil +} + +func QueryTeamByName(name string) (*Team, error) { + var team Team + if err := Db.Where("name = ?", name).First(&team).Error; err != nil { + return nil, err + } + return &team, nil +} + +// QueryTeamByUserId gets a team by user ID +func QueryTeamByUserId(userID uint) (*Team, error) { + DBMux.Lock() + defer DBMux.Unlock() + + var user User + if err := Db.First(&user, userID).Error; err != nil { + return nil, nil // User not found, return nil team + } + + if user.TeamID == 0 { + return nil, nil // User not in a team + } + + var team Team + err := Db.First(&team, user.TeamID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // Team not found, just return nil instead of error + } + return nil, err // Return other errors + } + + return &team, nil +} + +// GetAllTeams gets all teams ordered by score +func GetAllTeams() ([]Team, error) { + var teams []Team + + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Order("score desc").Find(&teams) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + + return teams, tx.Error +} diff --git a/core/database/user.go b/core/database/user.go index e2fe1b7a..26aaa07b 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -32,6 +32,11 @@ type User struct { SshKey string Status uint `gorm:"not null;default:0"` // 0 for unbanned, 1 for banned Score uint `gorm:"default:0"` + + // Team-related fields + TeamID uint `gorm:"default:null"` + Team *Team `gorm:"foreignKey:TeamID"` + IsTeamCaptain bool `gorm:"default:false"` } // Queries all the users entries where the column represented by key @@ -143,14 +148,20 @@ func CreateUserEntry(user *User) error { // Update an entry for the user in the User table func UpdateUser(user *User, m map[string]interface{}) error { - - DBMux.Lock() - defer DBMux.Unlock() - - return Db.Model(user).Updates(m).Error + DBMux.Lock() + defer DBMux.Unlock() + + return Db.Transaction(func(tx *gorm.DB) error { + var userToUpdate User + if err := tx.First(&userToUpdate, user.ID).Error; err != nil { + return err + } + + return tx.Model(&userToUpdate).Updates(m).Error + }) } -//Get Related Challenges +// Get Related Challenges func GetRelatedChallenges(user *User) ([]Challenge, error) { var challenges []Challenge @@ -182,7 +193,7 @@ func CheckPreviousSubmissions(userId uint, challId uint) (bool, error) { return (count >= 1), tx.Error } -//hook after create +// hook after create func (user *User) AfterCreate(tx *gorm.DB) error { if user.SshKey == "" { return nil @@ -193,7 +204,7 @@ func (user *User) AfterCreate(tx *gorm.DB) error { return nil } -//hook after update +// hook after update func (user *User) AfterUpdate(tx *gorm.DB) error { iFace, _ := tx.InstanceGet("gorm:update_attrs") if iFace == nil { @@ -257,7 +268,7 @@ func generateContentAuthorizedKeyFile(user *User) ([]byte, error) { return authKey.Bytes(), nil } -//adds to authorized keys +// adds to authorized keys func addToAuthorizedKeys(user *User) error { if config.Cfg == nil { log.Warn("No config initialized, skipping add to authorized keys hook") @@ -306,3 +317,157 @@ func deleteFromAuthorizedKeys(user *User) error { } return nil } + +// GetUserTeam gets the team of a user +func GetUserTeam(userID uint) (*Team, error) { + var user User + + DBMux.Lock() + defer DBMux.Unlock() + + if err := Db.Preload("Team").First(&user, userID).Error; err != nil { + return nil, fmt.Errorf("user not found") + } + + if user.TeamID == 0 { + return nil, fmt.Errorf("user not in a team") + } + + return user.Team, nil +} + +// IsUserTeamCaptain checks if user is a team captain +func IsUserTeamCaptain(userID uint) (bool, error) { + var user User + + DBMux.Lock() + defer DBMux.Unlock() + + if err := Db.First(&user, userID).Error; err != nil { + return false, fmt.Errorf("user not found") + } + + return user.IsTeamCaptain, nil +} + +// GetTeammates gets all teammates of a user +func GetTeammates(userID uint) ([]User, error) { + var user User + + DBMux.Lock() + defer DBMux.Unlock() + + if err := Db.First(&user, userID).Error; err != nil { + return nil, fmt.Errorf("user not found") + } + + if user.TeamID == 0 { + return nil, fmt.Errorf("user not in a team") + } + + var teammates []User + if err := Db.Where("team_id = ? AND id != ?", user.TeamID, userID).Find(&teammates).Error; err != nil { + return nil, err + } + + return teammates, nil +} + +// LeaveTeam makes a user leave their team +func LeaveTeam(userID uint) error { + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Begin() + + var user User + if err := tx.First(&user, userID).Error; err != nil { + tx.Rollback() + return fmt.Errorf("user not found") + } + + if user.TeamID == 0 { + tx.Rollback() + return fmt.Errorf("user not in a team") + } + + if user.IsTeamCaptain { + tx.Rollback() + return fmt.Errorf("team captain cannot leave, must transfer captaincy first") + } + + if err := tx.Model(&user).Updates(map[string]interface{}{ + "TeamID": 0, + "IsTeamCaptain": false, + }).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to leave team") + } + + return tx.Commit().Error +} + +// TransferCaptaincy transfers team captain role to another team member +func TransferCaptaincy(currentCaptainID uint, newCaptainID uint) error { + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Begin() + + // Check current captain + var currentCaptain User + if err := tx.First(¤tCaptain, currentCaptainID).Error; err != nil { + tx.Rollback() + return fmt.Errorf("current captain not found") + } + + if !currentCaptain.IsTeamCaptain { + tx.Rollback() + return fmt.Errorf("user is not a team captain") + } + + // Check new captain + var newCaptain User + if err := tx.First(&newCaptain, newCaptainID).Error; err != nil { + tx.Rollback() + return fmt.Errorf("new captain not found") + } + + if newCaptain.TeamID != currentCaptain.TeamID { + tx.Rollback() + return fmt.Errorf("new captain must be in the same team") + } + + // Transfer captaincy + if err := tx.Model(¤tCaptain).Update("IsTeamCaptain", false).Error; err != nil { + tx.Rollback() + return err + } + + if err := tx.Model(&newCaptain).Update("IsTeamCaptain", true).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +func GetTeamByName(userName string) (*Team, error) { + var user User + + DBMux.Lock() + defer DBMux.Unlock() + + // Fetch the user by their name along with their team + if err := Db.Preload("Team").Where("name = ?", userName).First(&user).Error; err != nil { + return nil, fmt.Errorf("user not found: %v", err) + } + + // Check if the user has an associated team + if user.TeamID == 0 { + return nil, fmt.Errorf("user not part of any team") + } + + // Return the associated team + return user.Team, nil +}