Files
MyClub/internal/controllers/poll_controller.go
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

623 lines
17 KiB
Go

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,
})
}