package controllers import ( "crypto/sha256" "encoding/hex" "fmt" "net/http" "strconv" "time" "fotbal-club/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type PollController struct { DB *gorm.DB } func NewPollController(db *gorm.DB) *PollController { return &PollController{DB: db} } // GetPolls returns list of polls (admin or public) func (pc *PollController) GetPolls(c *gin.Context) { var polls []models.Poll query := pc.DB.Preload("Options").Preload("Category").Preload("Creator"). Preload("RelatedArticle").Preload("RelatedEvent") // Check if admin request if c.GetBool("isAdmin") { // Admin sees all polls status := c.Query("status") if status != "" { query = query.Where("status = ?", status) } } else { // Public sees only active polls query = query.Where("status = ?", "active") now := time.Now() query = query.Where("(start_date IS NULL OR start_date <= ?) AND (end_date IS NULL OR end_date >= ?)", now, now) } // Featured filter if c.Query("featured") == "true" { query = query.Where("featured = ?", true) } // Filter by relationships if articleID := c.Query("article_id"); articleID != "" { query = query.Where("related_article_id = ?", articleID) } if eventID := c.Query("event_id"); eventID != "" { query = query.Where("related_event_id = ?", eventID) } if videoURL := c.Query("video_url"); videoURL != "" { query = query.Where("related_video_url = ?", videoURL) } // Order query = query.Order("created_at DESC") if err := query.Find(&polls).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch polls"}) return } c.JSON(http.StatusOK, polls) } // GetPoll returns a single poll by ID func (pc *PollController) GetPoll(c *gin.Context) { id := c.Param("id") var poll models.Poll query := pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB { return db.Order("display_order ASC, id ASC") }).Preload("Options.Player").Preload("Category") if err := query.First(&poll, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"}) return } // Check if user has voted hasVoted := false if userID, exists := c.Get("userID"); exists && userID != nil { var count int64 pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id = ?", poll.ID, userID).Count(&count) hasVoted = count > 0 } else { // Check by IP hash or session token ipHash := pc.hashIP(c.ClientIP()) sessionToken := c.GetHeader("X-Session-Token") if sessionToken == "" { sessionToken = c.Query("session_token") } var count int64 query := pc.DB.Model(&models.PollVote{}).Where("poll_id = ?", poll.ID) if sessionToken != "" { query = query.Where("session_token = ?", sessionToken) } else { query = query.Where("ip_hash = ?", ipHash) } query.Count(&count) hasVoted = count > 0 } // Add metadata response := gin.H{ "poll": poll, "has_voted": hasVoted, "is_active": poll.IsActive(), "can_show_results": poll.CanShowResults(hasVoted), } c.JSON(http.StatusOK, response) } // CreatePoll creates a new poll (admin only) func (pc *PollController) CreatePoll(c *gin.Context) { var input struct { Title string `json:"title" binding:"required"` Description string `json:"description"` Type string `json:"type"` Status string `json:"status"` StartDate *time.Time `json:"start_date"` EndDate *time.Time `json:"end_date"` AllowMultiple bool `json:"allow_multiple"` MaxChoices int `json:"max_choices"` ShowResults string `json:"show_results"` RequireAuth bool `json:"require_auth"` AllowGuestVote bool `json:"allow_guest_vote"` Featured bool `json:"featured"` CategoryID *uint `json:"category_id"` RelatedMatchID *uint `json:"related_match_id"` RelatedArticleID *uint `json:"related_article_id"` RelatedEventID *uint `json:"related_event_id"` RelatedVideoURL string `json:"related_video_url"` ImageURL string `json:"image_url"` Options []struct { Text string `json:"text" binding:"required"` Description string `json:"description"` ImageURL string `json:"image_url"` DisplayOrder int `json:"display_order"` PlayerID *uint `json:"player_id"` } `json:"options" binding:"required,min=2"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get user ID userID, _ := c.Get("userID") // Create poll poll := models.Poll{ Title: input.Title, Description: input.Description, Type: input.Type, Status: input.Status, StartDate: input.StartDate, EndDate: input.EndDate, AllowMultiple: input.AllowMultiple, MaxChoices: input.MaxChoices, ShowResults: input.ShowResults, RequireAuth: input.RequireAuth, AllowGuestVote: input.AllowGuestVote, Featured: input.Featured, CategoryID: input.CategoryID, RelatedMatchID: input.RelatedMatchID, RelatedArticleID: input.RelatedArticleID, RelatedEventID: input.RelatedEventID, RelatedVideoURL: input.RelatedVideoURL, ImageURL: input.ImageURL, CreatedBy: userID.(uint), } // Set defaults if poll.Type == "" { poll.Type = "single" } if poll.Status == "" { poll.Status = "draft" } if poll.ShowResults == "" { poll.ShowResults = "after_vote" } if poll.MaxChoices == 0 { poll.MaxChoices = 1 } // Start transaction tx := pc.DB.Begin() if err := tx.Create(&poll).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create poll"}) return } // Create options for _, opt := range input.Options { option := models.PollOption{ PollID: poll.ID, Text: opt.Text, Description: opt.Description, ImageURL: opt.ImageURL, DisplayOrder: opt.DisplayOrder, PlayerID: opt.PlayerID, } if err := tx.Create(&option).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create poll option"}) return } } tx.Commit() // Reload with relations pc.DB.Preload("Options").Preload("Category").First(&poll, poll.ID) c.JSON(http.StatusCreated, poll) } // UpdatePoll updates an existing poll (admin only) func (pc *PollController) UpdatePoll(c *gin.Context) { id := c.Param("id") var poll models.Poll if err := pc.DB.First(&poll, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"}) return } var input struct { Title *string `json:"title"` Description *string `json:"description"` Type *string `json:"type"` Status *string `json:"status"` StartDate *time.Time `json:"start_date"` EndDate *time.Time `json:"end_date"` AllowMultiple *bool `json:"allow_multiple"` MaxChoices *int `json:"max_choices"` ShowResults *string `json:"show_results"` RequireAuth *bool `json:"require_auth"` AllowGuestVote *bool `json:"allow_guest_vote"` Featured *bool `json:"featured"` CategoryID *uint `json:"category_id"` RelatedMatchID *uint `json:"related_match_id"` RelatedArticleID *uint `json:"related_article_id"` RelatedEventID *uint `json:"related_event_id"` RelatedVideoURL *string `json:"related_video_url"` ImageURL *string `json:"image_url"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Update fields if input.Title != nil { poll.Title = *input.Title } if input.Description != nil { poll.Description = *input.Description } if input.Type != nil { poll.Type = *input.Type } if input.Status != nil { poll.Status = *input.Status } if input.StartDate != nil { poll.StartDate = input.StartDate } if input.EndDate != nil { poll.EndDate = input.EndDate } if input.AllowMultiple != nil { poll.AllowMultiple = *input.AllowMultiple } if input.MaxChoices != nil { poll.MaxChoices = *input.MaxChoices } if input.ShowResults != nil { poll.ShowResults = *input.ShowResults } if input.RequireAuth != nil { poll.RequireAuth = *input.RequireAuth } if input.AllowGuestVote != nil { poll.AllowGuestVote = *input.AllowGuestVote } if input.Featured != nil { poll.Featured = *input.Featured } if input.CategoryID != nil { poll.CategoryID = input.CategoryID } // For relationships, directly set the values (including nil to unlink) // GORM's Save will handle NULL values correctly poll.RelatedMatchID = input.RelatedMatchID poll.RelatedArticleID = input.RelatedArticleID poll.RelatedEventID = input.RelatedEventID if input.RelatedVideoURL != nil { poll.RelatedVideoURL = *input.RelatedVideoURL } if input.ImageURL != nil { poll.ImageURL = *input.ImageURL } if err := pc.DB.Save(&poll).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update poll"}) return } // Reload with relations pc.DB.Preload("Options").Preload("Category").First(&poll, poll.ID) c.JSON(http.StatusOK, poll) } // DeletePoll deletes a poll (admin only) func (pc *PollController) DeletePoll(c *gin.Context) { id := c.Param("id") var poll models.Poll if err := pc.DB.First(&poll, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"}) return } // Soft delete if err := pc.DB.Delete(&poll).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete poll"}) return } c.JSON(http.StatusOK, gin.H{"message": "Poll deleted successfully"}) } // Vote handles vote submission func (pc *PollController) Vote(c *gin.Context) { id := c.Param("id") var input struct { OptionIDs []uint `json:"option_ids" binding:"required,min=1"` SessionToken string `json:"session_token"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()}) return } // Get poll var poll models.Poll if err := pc.DB.Preload("Options").First(&poll, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"}) return } // Check if poll is active if !poll.IsActive() { c.JSON(http.StatusBadRequest, gin.H{"error": "Poll is not currently accepting votes"}) return } // Check authentication requirement userID, hasUser := c.Get("userID") if poll.RequireAuth && !hasUser { c.JSON(http.StatusUnauthorized, gin.H{"error": "Login required to vote"}) return } if !poll.AllowGuestVote && !hasUser { c.JSON(http.StatusUnauthorized, gin.H{"error": "Guest voting not allowed"}) return } // Check multiple choice limits if !poll.AllowMultiple && len(input.OptionIDs) > 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "Only one choice allowed"}) return } if poll.AllowMultiple && len(input.OptionIDs) > poll.MaxChoices { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Maximum %d choices allowed", poll.MaxChoices)}) return } // Check if already voted ipHash := pc.hashIP(c.ClientIP()) sessionToken := input.SessionToken if sessionToken == "" { sessionToken = c.GetHeader("X-Session-Token") } var existingVoteCount int64 query := pc.DB.Model(&models.PollVote{}).Where("poll_id = ?", poll.ID) if hasUser { query = query.Where("user_id = ?", userID) } else if sessionToken != "" { query = query.Where("session_token = ?", sessionToken) } else { query = query.Where("ip_hash = ?", ipHash) } query.Count(&existingVoteCount) if existingVoteCount > 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "You have already voted in this poll"}) return } // Validate option IDs belong to this poll validOptions := make(map[uint]bool) for _, opt := range poll.Options { validOptions[opt.ID] = true } for _, optID := range input.OptionIDs { if !validOptions[optID] { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid option ID"}) return } } // Start transaction tx := pc.DB.Begin() // Create votes userAgent := c.Request.UserAgent() for _, optionID := range input.OptionIDs { vote := models.PollVote{ PollID: poll.ID, OptionID: optionID, IPHash: ipHash, UserAgent: userAgent, SessionToken: sessionToken, } if hasUser { uid := userID.(uint) vote.UserID = &uid } if err := tx.Create(&vote).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record vote"}) return } // Increment option vote count if err := tx.Model(&models.PollOption{}).Where("id = ?", optionID).UpdateColumn("vote_count", gorm.Expr("vote_count + ?", 1)).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vote count"}) return } } // Increment poll total votes if err := tx.Model(&models.Poll{}).Where("id = ?", poll.ID).UpdateColumn("total_votes", gorm.Expr("total_votes + ?", 1)).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update total votes"}) return } tx.Commit() // Reload poll with updated counts pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB { return db.Order("display_order ASC, id ASC") }).First(&poll, poll.ID) c.JSON(http.StatusOK, gin.H{ "message": "Vote recorded successfully", "poll": poll, }) } // GetPollResults returns poll results func (pc *PollController) GetPollResults(c *gin.Context) { id := c.Param("id") var poll models.Poll if err := pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB { return db.Order("display_order ASC, id ASC") }).First(&poll, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"}) return } // Check if user can see results hasVoted := false if userID, exists := c.Get("userID"); exists && userID != nil { var count int64 pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id = ?", poll.ID, userID).Count(&count) hasVoted = count > 0 } else { ipHash := pc.hashIP(c.ClientIP()) sessionToken := c.GetHeader("X-Session-Token") if sessionToken == "" { sessionToken = c.Query("session_token") } var count int64 query := pc.DB.Model(&models.PollVote{}).Where("poll_id = ?", poll.ID) if sessionToken != "" { query = query.Where("session_token = ?", sessionToken) } else { query = query.Where("ip_hash = ?", ipHash) } query.Count(&count) hasVoted = count > 0 } if !poll.CanShowResults(hasVoted) && !c.GetBool("isAdmin") { c.JSON(http.StatusForbidden, gin.H{"error": "Results are not available yet"}) return } // Calculate percentages results := make([]gin.H, len(poll.Options)) for i, option := range poll.Options { percentage := 0.0 if poll.TotalVotes > 0 { percentage = float64(option.VoteCount) / float64(poll.TotalVotes) * 100 } results[i] = gin.H{ "option_id": option.ID, "text": option.Text, "vote_count": option.VoteCount, "percentage": percentage, "image_url": option.ImageURL, "player_id": option.PlayerID, } } c.JSON(http.StatusOK, gin.H{ "poll_id": poll.ID, "title": poll.Title, "total_votes": poll.TotalVotes, "results": results, }) } // Helper function to hash IP addresses func (pc *PollController) hashIP(ip string) string { hash := sha256.Sum256([]byte(ip + "poll-salt-2025")) return hex.EncodeToString(hash[:]) } // GetPollStats returns statistics for admin (admin only) func (pc *PollController) GetPollStats(c *gin.Context) { id := c.Param("id") pollID, err := strconv.Atoi(id) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid poll ID"}) return } var poll models.Poll if err := pc.DB.Preload("Options").First(&poll, pollID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"}) return } // Get vote distribution over time var votesByDay []struct { Date string `json:"date"` Count int `json:"count"` } pc.DB.Model(&models.PollVote{}). Select("DATE(created_at) as date, COUNT(*) as count"). Where("poll_id = ?", pollID). Group("DATE(created_at)"). Order("date ASC"). Scan(&votesByDay) // Get authenticated vs guest votes var authVotes, guestVotes int64 pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id IS NOT NULL", pollID).Count(&authVotes) pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id IS NULL", pollID).Count(&guestVotes) c.JSON(http.StatusOK, gin.H{ "poll": poll, "votes_by_day": votesByDay, "authenticated_votes": authVotes, "guest_votes": guestVotes, }) }