package handlers import ( "fmt" "net/http" "strconv" "strings" "time" "game-admin/internal/models" "github.com/gin-gonic/gin" "github.com/uptrace/bun" ) func (h *GameHandler) ListLeaderboards(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil { renderLeaderboardModuleError(c, err) return } var leaderboards []models.Leaderboard err = h.db.NewSelect(). Model(&leaderboards). Relation("Groups", func(q *bun.SelectQuery) *bun.SelectQuery { return q.OrderExpr("lbg.id ASC") }). Where("lb.game_id = ?", gameID). OrderExpr("lb.id DESC"). Scan(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, leaderboards) } func (h *GameHandler) GetLeaderboard(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"}) return } if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil { renderLeaderboardModuleError(c, err) return } leaderboard, err := h.getLeaderboard(c, gameID, leaderboardID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"}) return } c.JSON(http.StatusOK, leaderboard) } func (h *GameHandler) CreateLeaderboard(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } var req models.UpsertLeaderboardRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := validateUpsertLeaderboardRequest(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil { renderLeaderboardModuleError(c, err) return } isActive := true if req.IsActive != nil { isActive = *req.IsActive } leaderboard := &models.Leaderboard{ GameID: gameID, Key: strings.TrimSpace(req.Key), Name: strings.TrimSpace(req.Name), SortOrder: req.SortOrder, PeriodType: req.PeriodType, IsActive: isActive, CreatedAt: time.Now(), UpdatedAt: time.Now(), } _, err = h.db.NewInsert().Model(leaderboard).Exec(c) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "Leaderboard key already exists"}) return } created, err := h.getLeaderboard(c, gameID, leaderboard.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, created) } func (h *GameHandler) UpdateLeaderboard(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"}) return } var req models.UpsertLeaderboardRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := validateUpsertLeaderboardRequest(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil { renderLeaderboardModuleError(c, err) return } current, err := h.getLeaderboard(c, gameID, leaderboardID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"}) return } isActive := current.IsActive if req.IsActive != nil { isActive = *req.IsActive } _, err = h.db.NewUpdate(). Model((*models.Leaderboard)(nil)). Set(`"key" = ?`, strings.TrimSpace(req.Key)). Set("name = ?", strings.TrimSpace(req.Name)). Set("sort_order = ?", req.SortOrder). Set("period_type = ?", req.PeriodType). Set("is_active = ?", isActive). Set("updated_at = ?", time.Now()). Where("id = ? AND game_id = ?", leaderboardID, gameID). Exec(c) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "Could not update leaderboard"}) return } updated, err := h.getLeaderboard(c, gameID, leaderboardID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, updated) } func (h *GameHandler) DeleteLeaderboard(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"}) return } if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil { renderLeaderboardModuleError(c, err) return } if _, err := h.getLeaderboard(c, gameID, leaderboardID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"}) return } tx, err := h.db.BeginTx(c, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed := false defer func() { if !committed { _ = tx.Rollback() } }() var groups []models.LeaderboardGroup if err := tx.NewSelect().Model(&groups).Where("leaderboard_id = ?", leaderboardID).Scan(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(groups) > 0 { groupIDs := make([]int64, 0, len(groups)) for _, group := range groups { groupIDs = append(groupIDs, group.ID) } if _, err := tx.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id IN (?)", bun.In(groupIDs)).Exec(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if _, err := tx.NewDelete().Model((*models.LeaderboardGroup)(nil)).Where("leaderboard_id = ?", leaderboardID).Exec(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if _, err := tx.NewDelete().Model((*models.LeaderboardScore)(nil)).Where("leaderboard_id = ?", leaderboardID).Exec(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if _, err := tx.NewDelete().Model((*models.Leaderboard)(nil)).Where("id = ? AND game_id = ?", leaderboardID, gameID).Exec(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Commit(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed = true c.JSON(http.StatusOK, gin.H{"message": "Leaderboard deleted"}) } func (h *GameHandler) ListLeaderboardGroups(c *gin.Context) { leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"}) return } leaderboard, err := h.getLeaderboardByID(c, leaderboardID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } var groups []models.LeaderboardGroup err = h.db.NewSelect(). Model(&groups). Relation("Members", func(q *bun.SelectQuery) *bun.SelectQuery { return q.OrderExpr("lbgm.id ASC") }). Where("lbg.leaderboard_id = ?", leaderboardID). OrderExpr("lbg.id ASC"). Scan(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, groups) } func (h *GameHandler) CreateLeaderboardGroup(c *gin.Context) { leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"}) return } leaderboard, err := h.getLeaderboardByID(c, leaderboardID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"}) return } var req models.UpsertLeaderboardGroupRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := validateUpsertLeaderboardGroupRequest(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } isDefault := false if req.IsDefault != nil { isDefault = *req.IsDefault } group := &models.LeaderboardGroup{ LeaderboardID: leaderboardID, Key: strings.TrimSpace(req.Key), Name: strings.TrimSpace(req.Name), IsDefault: isDefault, CreatedAt: time.Now(), UpdatedAt: time.Now(), } _, err = h.db.NewInsert().Model(group).Exec(c) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "Leaderboard group key already exists"}) return } c.JSON(http.StatusCreated, group) } func (h *GameHandler) UpdateLeaderboardGroup(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"}) return } group, leaderboard, err := h.getLeaderboardGroup(c, groupID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"}) return } var req models.UpsertLeaderboardGroupRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := validateUpsertLeaderboardGroupRequest(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } isDefault := group.IsDefault if req.IsDefault != nil { isDefault = *req.IsDefault } _, err = h.db.NewUpdate(). Model((*models.LeaderboardGroup)(nil)). Set(`"key" = ?`, strings.TrimSpace(req.Key)). Set("name = ?", strings.TrimSpace(req.Name)). Set("is_default = ?", isDefault). Set("updated_at = ?", time.Now()). Where("id = ?", groupID). Exec(c) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "Could not update leaderboard group"}) return } updated, _, err := h.getLeaderboardGroup(c, groupID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, updated) } func (h *GameHandler) DeleteLeaderboardGroup(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"}) return } _, leaderboard, err := h.getLeaderboardGroup(c, groupID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } tx, err := h.db.BeginTx(c, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed := false defer func() { if !committed { _ = tx.Rollback() } }() if _, err := tx.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id = ?", groupID).Exec(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if _, err := tx.NewDelete().Model((*models.LeaderboardGroup)(nil)).Where("id = ?", groupID).Exec(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Commit(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed = true c.JSON(http.StatusOK, gin.H{"message": "Leaderboard group deleted"}) } func (h *GameHandler) AddLeaderboardGroupMember(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"}) return } group, leaderboard, err := h.getLeaderboardGroup(c, groupID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } var req models.AddLeaderboardGroupMemberRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureUserBelongsToGame(c, req.UserID, leaderboard.GameID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } member := &models.LeaderboardGroupMember{ GroupID: group.ID, UserID: req.UserID, CreatedAt: time.Now(), } _, err = h.db.NewInsert().Model(member).Exec(c) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "User already in group"}) return } c.JSON(http.StatusCreated, member) } func (h *GameHandler) DeleteLeaderboardGroupMember(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"}) return } userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"}) return } _, leaderboard, err := h.getLeaderboardGroup(c, groupID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } res, err := h.db.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id = ? AND user_id = ?", groupID, userID).Exec(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if affected, _ := res.RowsAffected(); affected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Group member not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "Group member deleted"}) } func (h *GameHandler) UpsertLeaderboardScore(c *gin.Context) { leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"}) return } leaderboard, err := h.getLeaderboardByID(c, leaderboardID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } var req models.UpsertLeaderboardScoreRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureUserGameBelongsToGame(c, req.UserGameID, leaderboard.GameID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } now := time.Now() score := &models.LeaderboardScore{ LeaderboardID: leaderboardID, UserGameID: req.UserGameID, Score: req.Score, CreatedAt: now, UpdatedAt: now, } _, err = h.db.NewInsert().Model(score). On("CONFLICT (leaderboard_id, user_game_id) DO UPDATE"). Set("score = EXCLUDED.score"). Set("updated_at = EXCLUDED.updated_at"). Exec(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Leaderboard score saved"}) } func (h *GameHandler) GetLeaderboardRankings(c *gin.Context) { leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"}) return } leaderboard, err := h.getLeaderboardByID(c, leaderboardID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"}) return } if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil { renderLeaderboardModuleError(c, err) return } limit := 50 if rawLimit := c.Query("limit"); rawLimit != "" { parsed, err := strconv.Atoi(rawLimit) if err != nil || parsed <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid limit"}) return } if parsed > 200 { parsed = 200 } limit = parsed } query := h.db.NewSelect(). TableExpr("leaderboard_scores AS lbs"). ColumnExpr("lbs.user_game_id AS user_game_id"). ColumnExpr("ug.user_id AS user_id"). ColumnExpr("u.username AS username"). ColumnExpr("lbs.score AS score"). Join("JOIN user_games AS ug ON ug.id = lbs.user_game_id"). Join("JOIN users AS u ON u.id = ug.user_id"). Where("lbs.leaderboard_id = ?", leaderboardID) if rawGroupID := c.Query("group_id"); rawGroupID != "" { groupID, err := strconv.ParseInt(rawGroupID, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group_id"}) return } group, _, err := h.getLeaderboardGroup(c, groupID) if err != nil || group.LeaderboardID != leaderboardID { c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"}) return } query = query.Join("JOIN leaderboard_group_members AS lbgm ON lbgm.user_id = ug.user_id"). Where("lbgm.group_id = ?", groupID) } switch leaderboard.SortOrder { case models.LeaderboardSortOrderAsc: query = query.OrderExpr("lbs.score ASC, lbs.updated_at ASC") default: query = query.OrderExpr("lbs.score DESC, lbs.updated_at ASC") } var rows []models.LeaderboardRankItem if err := query.Limit(limit).Scan(c, &rows); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } for i := range rows { rows[i].Rank = int64(i + 1) } c.JSON(http.StatusOK, gin.H{ "leaderboard": leaderboard, "items": rows, }) } func (h *GameHandler) ensureLeaderboardModuleEnabled(c *gin.Context, gameID int64) error { exists, err := h.gameExists(c, gameID) if err != nil { return err } if !exists { return fmt.Errorf("game_not_found") } hasModule, err := h.gameHasModule(c, gameID, "leaderboard") if err != nil { return err } if !hasModule { return fmt.Errorf("leaderboard_module_disabled") } return nil } func renderLeaderboardModuleError(c *gin.Context, err error) { switch err.Error() { case "game_not_found": c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"}) case "leaderboard_module_disabled": c.JSON(http.StatusForbidden, gin.H{"error": "Leaderboard module is not enabled for this game"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } } func validateUpsertLeaderboardRequest(req *models.UpsertLeaderboardRequest) error { req.Key = strings.TrimSpace(req.Key) req.Name = strings.TrimSpace(req.Name) if req.Key == "" { return fmt.Errorf("key is required") } if req.Name == "" { return fmt.Errorf("name is required") } if req.SortOrder != models.LeaderboardSortOrderAsc && req.SortOrder != models.LeaderboardSortOrderDesc { return fmt.Errorf("sort_order must be asc or desc") } switch req.PeriodType { case models.LeaderboardPeriodAllTime, models.LeaderboardPeriodDaily, models.LeaderboardPeriodWeekly, models.LeaderboardPeriodMonthly: default: return fmt.Errorf("unsupported period_type") } return nil } func validateUpsertLeaderboardGroupRequest(req *models.UpsertLeaderboardGroupRequest) error { req.Key = strings.TrimSpace(req.Key) req.Name = strings.TrimSpace(req.Name) if req.Key == "" { return fmt.Errorf("key is required") } if req.Name == "" { return fmt.Errorf("name is required") } return nil } func (h *GameHandler) getLeaderboard(c *gin.Context, gameID, leaderboardID int64) (*models.Leaderboard, error) { leaderboard := new(models.Leaderboard) err := h.db.NewSelect(). Model(leaderboard). Relation("Groups", func(q *bun.SelectQuery) *bun.SelectQuery { return q.OrderExpr("lbg.id ASC") }). Where("lb.id = ? AND lb.game_id = ?", leaderboardID, gameID). Scan(c) if err != nil { return nil, err } return leaderboard, nil } func (h *GameHandler) getLeaderboardByID(c *gin.Context, leaderboardID int64) (*models.Leaderboard, error) { leaderboard := new(models.Leaderboard) err := h.db.NewSelect(). Model(leaderboard). Where("id = ?", leaderboardID). Scan(c) if err != nil { return nil, err } return leaderboard, nil } func (h *GameHandler) getLeaderboardGroup(c *gin.Context, groupID int64) (*models.LeaderboardGroup, *models.Leaderboard, error) { group := new(models.LeaderboardGroup) err := h.db.NewSelect().Model(group).Where("id = ?", groupID).Scan(c) if err != nil { return nil, nil, err } leaderboard, err := h.getLeaderboardByID(c, group.LeaderboardID) if err != nil { return nil, nil, err } return group, leaderboard, nil } func (h *GameHandler) ensureUserBelongsToGame(c *gin.Context, userID, gameID int64) error { exists, err := h.db.NewSelect(). Model((*models.UserGame)(nil)). Where("user_id = ? AND game_id = ?", userID, gameID). Exists(c) if err != nil { return err } if !exists { return fmt.Errorf("user is not connected to this game") } return nil } func (h *GameHandler) ensureUserGameBelongsToGame(c *gin.Context, userGameID, gameID int64) error { exists, err := h.db.NewSelect(). Model((*models.UserGame)(nil)). Where("id = ? AND game_id = ?", userGameID, gameID). Exists(c) if err != nil { return err } if !exists { return fmt.Errorf("user_game does not belong to this game") } return nil }