This commit is contained in:
Tomas Dvorak
2025-11-11 10:29:30 +01:00
parent d5b4faea61
commit 8762bde4bf
139 changed files with 7240 additions and 2870 deletions
@@ -1,6 +1,7 @@
package controllers
import (
"encoding/json"
"fmt"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -45,6 +46,15 @@ type CreateArticleRequest struct {
YouTubeVideoTitle string `json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
Attachments []AttachmentItem `json:"attachments"`
}
// AttachmentItem represents a single attachment entry for an article
type AttachmentItem struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size *int `json:"size,omitempty"`
}
// CreateArticle creates a new article with comprehensive error handling
@@ -243,6 +253,13 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
article.YouTubeVideoThumbnail = trimmed
}
// 15. Set attachments (serialize to JSON string)
if len(req.Attachments) > 0 {
if b, err := json.Marshal(req.Attachments); err == nil {
article.Attachments = string(b)
}
}
// 15. Save to database
if err := ac.DB.Create(&article).Error; err != nil {
logger.Error("CreateArticle: Database error: %v", err)
File diff suppressed because it is too large Load Diff
+63 -3
View File
@@ -103,6 +103,8 @@ func (cc *CommentController) Unreact(c *gin.Context) {
// Admin: list all comments with filters
func (cc *CommentController) AdminList(c *gin.Context) {
// Ensure tables exist (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{}, &models.CommentReport{}, &models.CommentReaction{})
var items []models.Comment
q := cc.DB.Preload("User").Model(&models.Comment{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
@@ -125,8 +127,21 @@ func (cc *CommentController) AdminList(c *gin.Context) {
_ = cc.DB.Table("comment_reports").Select("comment_id, COUNT(*) as cnt").Where("comment_id IN ?", ids).Group("comment_id").Scan(&rows).Error
for _, r := range rows { repCounts[r.CommentID] = r.Cnt }
}
// Compute admin likes (thumbs_up/like) per comment
adminLiked := map[uint]bool{}
if len(ids) > 0 {
type al struct{ CommentID uint }
var rows []al
_ = cc.DB.Table("comment_reactions as cr").
Select("cr.comment_id").
Joins("JOIN users u ON u.id = cr.user_id").
Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}).
Group("cr.comment_id").
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
}
out := make([]commentOutput, 0, len(items))
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; out = append(out, co) }
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; if adminLiked[r.ID] { co.AdminLiked = true }; out = append(out, co) }
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
@@ -216,6 +231,7 @@ type commentOutput struct {
SpamScore float32 `json:"spam_score,omitempty"`
SpamRules []string `json:"spam_rules,omitempty"`
Reports int `json:"reports,omitempty"`
AdminLiked bool `json:"admin_liked,omitempty"`
}
type userSlim struct {
@@ -273,14 +289,32 @@ func (cc *CommentController) GetComments(c *gin.Context) {
if page < 1 { page = 1 }
var total int64
q := cc.DB.Model(&models.Comment{}).Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible")
// Visibility rules:
// - Public/unauthenticated: only visible
// - Authenticated non-admin: visible + own hidden (awaiting moderation)
// - Admin: all
role, _ := c.Get("userRole")
uid, _ := c.Get("userID")
var where string
var args []interface{}
if role == "admin" {
where = "target_type = ? AND target_id = ?"
args = []interface{}{targetType, targetID}
} else if uid != nil {
where = "target_type = ? AND target_id = ? AND (status = 'visible' OR (status = 'hidden' AND user_id = ?))"
args = []interface{}{targetType, targetID, uid}
} else {
where = "target_type = ? AND target_id = ? AND status = 'visible'"
args = []interface{}{targetType, targetID}
}
q := cc.DB.Model(&models.Comment{}).Where(where, args...)
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var rows []models.Comment
if err := cc.DB.Preload("User").Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible").
if err := cc.DB.Preload("User").Where(where, args...).
Order("created_at ASC").
Offset((page-1)*pageSize).Limit(pageSize).
Find(&rows).Error; err != nil {
@@ -316,6 +350,19 @@ func (cc *CommentController) GetComments(c *gin.Context) {
for _, r := range rs { myReactions[r.CommentID] = r.Type }
}
}
// Admin liked map
adminLiked := map[uint]bool{}
if len(ids) > 0 {
type al struct{ CommentID uint }
var rows []al
_ = cc.DB.Table("comment_reactions as cr").
Select("cr.comment_id").
Joins("JOIN users u ON u.id = cr.user_id").
Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}).
Group("cr.comment_id").
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
}
// Preload user profiles for username + avatar (prefer animated when available)
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string; Username string }
profByUser := map[uint]up{}
@@ -337,6 +384,7 @@ func (cc *CommentController) GetComments(c *gin.Context) {
}
if rc, ok := reactionCounts[r.ID]; ok { co.Reactions = rc } else { co.Reactions = map[string]int{} }
if myReactions != nil { if t, ok := myReactions[r.ID]; ok { co.MyReaction = t } }
if adminLiked[r.ID] { co.AdminLiked = true }
out = append(out, co)
}
@@ -381,6 +429,18 @@ func (cc *CommentController) CreateComment(c *gin.Context) {
return
}
if in.ParentID != nil {
var parent models.Comment
if err := cc.DB.First(&parent, *in.ParentID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Rodičovský komentář nenalezen"})
return
}
if parent.TargetType != in.TargetType || parent.TargetID != in.TargetID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Rodičovský komentář neodpovídá cíli"})
return
}
}
userIDv, _ := c.Get("userID")
userID := userIDv.(uint)
File diff suppressed because it is too large Load Diff
+51 -24
View File
@@ -196,29 +196,35 @@ func (ec *EngagementController) PatchAvatar(c *gin.Context) {
// GET /api/v1/engagement/rewards (public)
func (ec *EngagementController) GetRewards(c *gin.Context) {
var unlock models.RewardItem
if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil {
unlock = models.RewardItem{
Name: "Odemknout vlastní avatar (upload)",
Type: "avatar_upload_unlock",
CostPoints: 250,
ImageURL: "",
Stock: -1,
Active: true,
}
_ = ec.DB.Create(&unlock).Error
} else {
if !unlock.Active {
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Update("active", true).Error
}
}
var items []models.RewardItem
q := ec.DB.Where("active = ?", true)
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
c.JSON(http.StatusOK, items)
var unlock models.RewardItem
if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil {
unlock = models.RewardItem{
Name: "Odemknout vlastní avatar (upload)",
Type: "avatar_upload_unlock",
CostPoints: 50,
ImageURL: "",
Stock: -1,
Active: true,
}
_ = ec.DB.Create(&unlock).Error
} else {
updates := map[string]interface{}{}
if !unlock.Active { updates["active"] = true }
if unlock.Stock != -1 { updates["stock"] = -1 }
if strings.TrimSpace(unlock.Name) == "" || unlock.Name != "Odemknout vlastní avatar (upload)" {
updates["name"] = "Odemknout vlastní avatar (upload)"
}
if len(updates) > 0 {
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Updates(updates).Error
}
}
var items []models.RewardItem
q := ec.DB.Where("active = ?", true)
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
c.JSON(http.StatusOK, items)
}
// POST /api/v1/engagement/redeem (auth)
@@ -419,6 +425,24 @@ func (ec *EngagementController) GetAchievements(c *gin.Context) {
// GET /api/v1/admin/engagement/rewards
func (ec *EngagementController) AdminListRewards(c *gin.Context) {
var items []models.RewardItem
var unlock models.RewardItem
if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil {
unlock = models.RewardItem{
Name: "Odemknout vlastní avatar (upload)",
Type: "avatar_upload_unlock",
CostPoints: 50,
ImageURL: "",
Stock: -1,
Active: true,
}
_ = ec.DB.Create(&unlock).Error
} else {
updates := map[string]interface{}{}
if !unlock.Active { updates["active"] = true }
if unlock.Stock != -1 { updates["stock"] = -1 }
if strings.TrimSpace(unlock.Name) == "" || unlock.Name != "Odemknout vlastní avatar (upload)" { updates["name"] = "Odemknout vlastní avatar (upload)" }
if len(updates) > 0 { _ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Updates(updates).Error }
}
q := ec.DB.Model(&models.RewardItem{})
if v := strings.TrimSpace(c.Query("active")); v != "" {
if v == "true" || v == "1" { q = q.Where("active = ?", true) }
@@ -470,13 +494,16 @@ func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
var existing models.RewardItem
_ = ec.DB.First(&existing, id).Error
if strings.EqualFold(existing.Type, "avatar_upload_unlock") {
// Disallow disabling or changing type of the mandatory reward
// Disallow disabling or changing type, and restrict updates to cost_points only
if body.Active != nil && *body.Active == false {
c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deactivated"}); return
}
if body.Type != nil && strings.ToLower(strings.TrimSpace(*body.Type)) != existing.Type {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type cannot be changed for this reward"}); return
}
if body.Name != nil || body.ImageURL != nil || body.Stock != nil || body.Active != nil || body.Metadata != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only price (cost_points) can be edited for this reward"}); return
}
}
updates := map[string]interface{}{}
if body.Name != nil { updates["name"] = strings.TrimSpace(*body.Name) }
+287
View File
@@ -0,0 +1,287 @@
package controllers
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/config"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type ErrorController struct {
DB *gorm.DB
}
func NewErrorController(db *gorm.DB) *ErrorController { return &ErrorController{DB: db} }
type ingestPayload struct {
Origin string `json:"origin"`
Language string `json:"language"`
Severity string `json:"severity"`
Message string `json:"message"`
Stack string `json:"stack"`
Component string `json:"component"`
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
URL string `json:"url"`
Method string `json:"method"`
Status int `json:"status"`
RequestID string `json:"request_id"`
UserID *uint `json:"user_id"`
SessionToken string `json:"session_token"`
Tags map[string]string `json:"tags"`
Context map[string]interface{} `json:"context"`
Env string `json:"env"`
Version string `json:"version"`
Hostname string `json:"hostname"`
OccurredAt *time.Time `json:"occurred_at"`
}
func (ec *ErrorController) Ingest(c *gin.Context) {
var p ingestPayload
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
var s models.Settings
_ = ec.DB.First(&s).Error
if p.Tags == nil { p.Tags = map[string]string{} }
if strings.TrimSpace(p.Origin) != "" {
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = strings.TrimSpace(p.Origin) }
} else {
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = "backend" }
}
if _, ok := p.Tags["instance_env"]; !ok {
if config.AppConfig != nil { p.Tags["instance_env"] = strings.TrimSpace(config.AppConfig.AppEnv) }
}
if p.Hostname == "" {
host := c.Request.Host
if idx := strings.Index(host, ":"); idx >= 0 { host = host[:idx] }
p.Hostname = host
}
if _, ok := p.Tags["instance_host"]; !ok && strings.TrimSpace(p.Hostname) != "" {
p.Tags["instance_host"] = strings.TrimSpace(p.Hostname)
}
if v := strings.TrimSpace(s.ClubName); v != "" { p.Tags["club_name"] = v }
if v := strings.TrimSpace(s.ClubID); v != "" { p.Tags["club_id"] = v }
if v := strings.TrimSpace(s.ClubLogoURL); v != "" { p.Tags["club_logo_url"] = v }
if p.Env == "" && config.AppConfig != nil { p.Env = config.AppConfig.AppEnv }
if p.Version == "" {
v := strings.TrimSpace(os.Getenv("APP_VERSION"))
if v != "" { p.Version = v }
}
if _, ok := p.Tags["container_id"]; !ok {
if h, err := os.Hostname(); err == nil {
if hv := strings.TrimSpace(h); hv != "" { p.Tags["container_id"] = hv }
}
}
var tags datatypes.JSON
var ctx datatypes.JSON
if p.Tags != nil {
if b, err := json.Marshal(p.Tags); err == nil { tags = datatypes.JSON(b) }
}
if p.Context != nil {
if b, err := json.Marshal(p.Context); err == nil { ctx = datatypes.JSON(b) }
}
occurred := time.Now()
if p.OccurredAt != nil && !p.OccurredAt.IsZero() {
occurred = *p.OccurredAt
}
rec := models.ErrorEvent{
Origin: p.Origin,
Language: p.Language,
Severity: p.Severity,
Message: p.Message,
Stack: p.Stack,
Component: p.Component,
File: p.File,
Line: p.Line,
Column: p.Column,
URL: p.URL,
Method: p.Method,
Status: p.Status,
RequestID: p.RequestID,
UserID: p.UserID,
SessionToken: p.SessionToken,
Tags: tags,
Context: ctx,
Env: p.Env,
Version: p.Version,
Hostname: p.Hostname,
OccurredAt: occurred,
}
if err := ec.DB.Create(&rec).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store"})
return
}
go func(p ingestPayload) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Prefer environment (config) for ingest URL and token; domain managed solely via .env
extURL := strings.TrimSpace(config.AppConfig.ErrorIngestURL)
token := strings.TrimSpace(config.AppConfig.ErrorIngestToken)
if extURL == "" {
// Default ingest URL: local when ERROR_LOCAL, otherwise external
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { extURL = "http://127.0.0.1:8083/api/v1/errors" } else { extURL = "https://errors.tdvorak.dev/api/v1/errors" }
}
if extURL == "" { return }
b, err := json.Marshal(p)
if err != nil { return }
post := func(u string) bool {
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
if err != nil { return false }
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Ingest-Token", token)
}
client := &http.Client{ Timeout: 4 * time.Second }
resp, err := client.Do(req)
if err != nil { return false }
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300
}
if post(extURL) { return }
if u, err := url.Parse(extURL); err == nil {
h := u.Hostname()
if h == "127.0.0.1" || h == "localhost" {
u.Host = strings.Replace(u.Host, h, "host.docker.internal", 1)
_ = post(u.String())
}
}
}(p)
c.JSON(http.StatusCreated, gin.H{"id": rec.ID})
}
type listResponse struct {
Items []models.ErrorEvent `json:"items"`
Total int64 `json:"total"`
}
func (ec *ErrorController) AdminList(c *gin.Context) {
q := ec.DB.Model(&models.ErrorEvent{})
if v := strings.TrimSpace(c.Query("origin")); v != "" { q = q.Where("origin = ?", v) }
if v := strings.TrimSpace(c.Query("severity")); v != "" { q = q.Where("severity = ?", v) }
if v := strings.TrimSpace(c.Query("method")); v != "" { q = q.Where("method = ?", v) }
if v := c.Query("status"); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("search")); v != "" {
like := "%" + v + "%"
q = q.Where("message ILIKE ? OR stack ILIKE ? OR url ILIKE ?", like, like, like)
}
if v := c.Query("from"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at >= ?", t) }
}
if v := c.Query("to"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at <= ?", t) }
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page < 1 { page = 1 }
if limit < 1 || limit > 200 { limit = 20 }
var total int64
_ = q.Count(&total).Error
var items []models.ErrorEvent
_ = q.Order("occurred_at DESC").Limit(limit).Offset((page-1)*limit).Find(&items).Error
c.JSON(http.StatusOK, listResponse{Items: items, Total: total})
}
func (ec *ErrorController) AdminGet(c *gin.Context) {
var rec models.ErrorEvent
if err := ec.DB.First(&rec, c.Param("id")).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load"})
return
}
c.JSON(http.StatusOK, rec)
}
// AdminListExternal proxies list to external error-review admin API
func (ec *ErrorController) AdminListExternal(c *gin.Context) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Domain managed via environment only (with default); token from env
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
if base == "" {
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
u.Path = strings.TrimRight(u.Path, "/") + "/errors"
u.RawQuery = c.Request.URL.RawQuery
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Admin-Token", token)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{ Timeout: 8 * time.Second }
resp, err := client.Do(req)
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
defer resp.Body.Close()
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
}
// AdminGetExternal proxies detail to external error-review admin API
func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Domain managed via environment only (with default); token from env
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
if base == "" {
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
id := strings.TrimSpace(c.Param("id"))
u.Path = strings.TrimRight(u.Path, "/") + "/errors/" + id
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Admin-Token", token)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{ Timeout: 8 * time.Second }
resp, err := client.Do(req)
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
defer resp.Body.Close()
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
}
+48 -22
View File
@@ -25,6 +25,12 @@ func (ctrl *EventController) GetEventByID(c *gin.Context) {
}
// If not public, allow only owner (when identified upstream)
if !ev.IsPublic {
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
c.JSON(http.StatusOK, ev)
return
}
}
if userID, exists := c.Get("userID"); !exists || ev.CreatedByID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Not allowed"})
return
@@ -112,19 +118,29 @@ func (ctrl *EventController) CreateEvent(c *gin.Context) {
}
func (ctrl *EventController) GetEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments")
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
var events []models.Event
query := ctrl.DB.Preload("Attachments")
// Admin sees all events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
@@ -132,17 +148,27 @@ func (ctrl *EventController) GetEvents(c *gin.Context) {
func (ctrl *EventController) GetUpcomingEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments").Where("start_time >= ?", time.Now()).Order("start_time ASC").Limit(5)
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
// Admin sees all upcoming events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
+41
View File
@@ -452,6 +452,47 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
}
}
if len(results) > 1 {
unique := make([]SearchResult, 0, len(results))
seenByID := map[string]int{}
seenByName := map[string]int{}
normKey := func(s string) string {
x := strings.ToLower(strings.TrimSpace(s))
x = strings.ReplaceAll(x, ".", "")
x = strings.ReplaceAll(x, " ", "")
return x
}
for _, r := range results {
id := strings.TrimSpace(r.ClubID)
if id != "" {
if idx, ok := seenByID[id]; ok {
// Keep the one with a logo
if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" {
unique[idx] = r
}
continue
}
seenByID[id] = len(unique)
unique = append(unique, r)
continue
}
key := normKey(r.Name)
if key == "" {
unique = append(unique, r)
continue
}
if idx, ok := seenByName[key]; ok {
if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" {
unique[idx] = r
}
continue
}
seenByName[key] = len(unique)
unique = append(unique, r)
}
results = unique
}
// respond and close the function
c.JSON(http.StatusOK, gin.H{
"query": q,
+1 -1
View File
@@ -33,7 +33,7 @@ func (fc *FilesController) GetStorageUsage(c *gin.Context) {
_ = fc.DB.First(&settings).Error
quotaMB := settings.StorageQuotaMB
if quotaMB <= 0 {
quotaMB = 1024
quotaMB = 5120
}
warnPct := settings.StorageWarnThreshold
if warnPct <= 0 {
+115 -107
View File
@@ -449,29 +449,21 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
// @Summary Seed default navigation
// @Description Creates default navigation items if the database is empty
// @Tags navigation
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/navigation/seed [post]
func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
// Check if navigation items already exist
var count int64
nc.DB.Model(&models.NavigationItem{}).Count(&count)
if count > 0 {
c.JSON(http.StatusOK, gin.H{
"message": "Navigation items already exist",
"count": count,
"seeded": false,
})
return
}
// Default frontend navigation items
frontendItems := []models.NavigationItem{
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
// Check existing counts for frontend and admin separately
var frontendCount int64
var adminCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
// Default frontend navigation items
frontendItems := []models.NavigationItem{
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
{Label: "Zápasy", Type: models.NavTypePage, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: false},
{Label: "Aktivity", Type: models.NavTypePage, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: false},
{Label: "Hráči", Type: models.NavTypePage, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: false},
@@ -483,111 +475,127 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
}
// Create items in a transaction with admin categories and children
err := nc.DB.Transaction(func(tx *gorm.DB) error {
for _, item := range frontendItems {
if err := tx.Create(&item).Error; err != nil {
return err
}
}
// Create items in a transaction with admin categories and children (seed missing parts only)
seededFrontend := false
seededAdmin := false
err := nc.DB.Transaction(func(tx *gorm.DB) error {
if frontendCount == 0 {
for _, item := range frontendItems {
if err := tx.Create(&item).Error; err != nil {
return err
}
}
seededFrontend = true
}
catOrder := 0
createCategory := func(label string) (*models.NavigationItem, error) {
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
catOrder++
if err := tx.Create(cat).Error; err != nil {
return nil, err
}
return cat, nil
}
if adminCount == 0 {
catOrder := 0
createCategory := func(label string) (*models.NavigationItem, error) {
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
catOrder++
if err := tx.Create(cat).Error; err != nil {
return nil, err
}
return cat, nil
}
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
child.ParentID = &pid
return tx.Create(child).Error
}
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
child.ParentID = &pid
return tx.Create(child).Error
}
zakladni, err := createCategory("Základní")
if err != nil { return err }
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
zakladni, err := createCategory("Základní")
if err != nil { return err }
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
sport, err := createCategory("Sport")
if err != nil { return err }
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
if err := createChild(sport, "Aktivity", "activities", 2); err != nil { return err }
if err := createChild(sport, "Hráči", "players", 3); err != nil { return err }
sport, err := createCategory("Sport")
if err != nil { return err }
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
if err := createChild(sport, "Hráči", "players", 2); err != nil { return err }
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil { return err }
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil { return err }
obsah, err := createCategory("Obsah")
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Kategorie", "categories", 1); err != nil { return err }
if err := createChild(obsah, "O klubu", "about", 2); err != nil { return err }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
obsah, err := createCategory("Obsah")
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
if err := createChild(obsah, "Kategorie", "categories", 2); err != nil { return err }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
media, err := createCategory("Média")
if err != nil { return err }
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
if err := createChild(media, "Média", "media", 2); err != nil { return err }
if err := createChild(media, "Soubory", "files", 3); err != nil { return err }
media, err := createCategory("Média")
if err != nil { return err }
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
if err := createChild(media, "Soubory", "files", 2); err != nil { return err }
kom, err := createCategory("Komunikace")
if err != nil { return err }
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
if err := createChild(kom, "Kontakty", "contacts", 1); err != nil { return err }
if err := createChild(kom, "Zpravodaj", "newsletter", 2); err != nil { return err }
kom, err := createCategory("Komunikace")
if err != nil { return err }
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil { return err }
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil { return err }
marketing, err := createCategory("Marketing")
if err != nil { return err }
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 3); err != nil { return err }
if err := createChild(marketing, "Ankety", "polls", 4); err != nil { return err }
if err := createChild(marketing, "Soutěže", "sweepstakes", 5); err != nil { return err }
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 6); err != nil { return err }
marketing, err := createCategory("Marketing")
if err != nil { return err }
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
if err := createChild(marketing, "Ankety", "polls", 3); err != nil { return err }
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil { return err }
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil { return err }
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil { return err }
nastroje, err := createCategory("Nástroje")
if err != nil { return err }
if err := createChild(nastroje, "Tabule (Scoreboard)", "scoreboard", 0); err != nil { return err }
if err := createChild(nastroje, "Scoreboard Remote", "scoreboard_remote", 1); err != nil { return err }
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 2); err != nil { return err }
nastroje, err := createCategory("Nástroje")
if err != nil { return err }
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil { return err }
nastaveni, err := createCategory("Nastavení")
if err != nil { return err }
if err := createChild(nastaveni, "Navigace", "navigation", 0); err != nil { return err }
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
if err := createChild(nastaveni, "Nastavení", "settings", 2); err != nil { return err }
if err := createChild(nastaveni, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
nastaveni, err := createCategory("Nastavení")
if err != nil { return err }
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil { return err }
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil { return err }
napoveda, err := createCategory("Nápověda")
if err != nil { return err }
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
napoveda, err := createCategory("Nápověda")
if err != nil { return err }
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
return nil
})
seededAdmin = true
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed navigation items"})
return
}
// Since creation is split, compute counts again
var total int64
nc.DB.Model(&models.NavigationItem{}).Count(&total)
var frontendCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
var adminCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
// Since creation is split, compute counts again
var total int64
nc.DB.Model(&models.NavigationItem{}).Count(&total)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
c.JSON(http.StatusOK, gin.H{
"message": "Default navigation items created successfully",
"count": total,
"frontend_count": frontendCount,
"admin_count": adminCount,
"seeded": true,
})
message := "Navigation items already exist"
if seededFrontend && seededAdmin {
message = "Default frontend and admin navigation created successfully"
} else if seededFrontend {
message = "Default frontend navigation created successfully"
} else if seededAdmin {
message = "Default admin navigation created successfully"
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"count": total,
"frontend_count": frontendCount,
"admin_count": adminCount,
"seeded": seededFrontend || seededAdmin,
"seeded_frontend": seededFrontend,
"seeded_admin": seededAdmin,
})
}
@@ -16,6 +16,7 @@ import (
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
)
@@ -140,6 +141,7 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
_ = os.MkdirAll(sponsorDir, 0o755)
saved := 0
created := make([]string, 0, 8)
if ctx.Request.MultipartForm != nil {
files := ctx.Request.MultipartForm.File["files"]
if len(files) == 0 {
@@ -164,18 +166,20 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
if _, err := io.Copy(&buf, src); err == nil {
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
saved++
created = append(created, "/uploads/sponsors/"+outName)
} else {
// Fallback: write original bytes with original extension
rawName := ensureUniqueFilename(sponsorDir, name)
rawPath := filepath.Join(sponsorDir, rawName)
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
saved++
created = append(created, "/uploads/sponsors/"+rawName)
}
}
_ = src.Close()
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": saved})
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
}
// DeleteSponsor deletes a sponsor logo by filename (?name=)
@@ -229,3 +233,64 @@ func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// PrefillSponsorsFromPage copies logo images from existing Sponsors into uploads/sponsors for overlay use.
// Optional JSON body: { "ids": [1,2,3] } to limit to specific sponsors.
func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
var body struct{ IDs []uint `json:"ids"` }
_ = ctx.ShouldBindJSON(&body)
var list []models.Sponsor
q := c.DB.Model(&models.Sponsor{})
if len(body.IDs) > 0 {
q = q.Where("id IN ?", body.IDs)
} else {
q = q.Where("is_active = ?", true)
}
if err := q.Find(&list).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
_ = os.MkdirAll(sponsorDir, 0o755)
created := make([]string, 0, len(list))
for _, s := range list {
logo := strings.TrimSpace(s.LogoURL)
if logo == "" { continue }
var data []byte
if strings.HasPrefix(logo, "/uploads/") {
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
if b, err := os.ReadFile(p); err == nil { data = b } else { continue }
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
resp, err := http.Get(logo)
if err != nil { continue }
func() {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return }
b, _ := io.ReadAll(resp.Body)
if len(b) > 0 { data = b }
}()
if len(data) == 0 { continue }
} else {
continue
}
base := sanitizeFilename(s.Name)
if base == "" {
seg := logo
if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] }
if j := strings.LastIndex(seg, "."); j >= 0 { seg = seg[:j] }
base = sanitizeFilename(seg)
if base == "" { base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
}
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
// fallback to raw write
rawName := ensureUniqueFilename(sponsorDir, base+".png")
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
created = append(created, "/uploads/sponsors/"+rawName)
} else {
created = append(created, "/uploads/sponsors/"+outName)
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
}
+18 -2
View File
@@ -345,8 +345,21 @@ func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
if s.ElapsedSeconds == 0 && s.Timer != "" {
s.ElapsedSeconds = parseTimerToSeconds(s.Timer)
}
s.TimerStartUnix = time.Now().Unix() - int64(s.ElapsedSeconds)
s.Running = true
// Respect caps similarly to computeTimer
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.Half >= 2 { cap = s.HalfLength * 120 }
if s.ElapsedSeconds >= cap {
// Already at or beyond cap; keep paused at cap
s.ElapsedSeconds = cap
s.Timer = formatSeconds(s.ElapsedSeconds)
s.Running = false
s.TimerStartUnix = 0
} else {
// Start from current elapsed
s.TimerStartUnix = time.Now().Unix() - int64(s.ElapsedSeconds)
s.Running = true
}
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
@@ -368,8 +381,11 @@ func (c *ScoreboardController) PauseTimer(ctx *gin.Context) {
// Cap and set display string
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.Half >= 2 { cap = s.HalfLength * 120 }
if s.ElapsedSeconds > cap { s.ElapsedSeconds = cap }
s.Timer = formatSeconds(s.ElapsedSeconds)
// Clear start marker when paused
s.TimerStartUnix = 0
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
+194 -172
View File
@@ -20,31 +20,35 @@ type SweepstakesController struct {
Email email.EmailService
}
func NewSweepstakesController(db *gorm.DB, es email.EmailService) *SweepstakesController {
return &SweepstakesController{DB: db, Email: es}
}
// Public: visualization data for a specific sweepstake (within visibility window)
// GET /api/v1/sweepstakes/:id/visual
func (sc *SweepstakesController) PublicVisualData(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
now := time.Now()
if s.VisibilityUntil == nil || now.After(*s.VisibilityUntil) || now.Before(s.EndAt) {
c.JSON(http.StatusNotFound, gin.H{"error":"Not available"}); return
}
var winners []struct{ UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"` }
_ = sc.DB.Table("sweepstake_winners").Select("user_id, prize_name").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
type entryRow struct {
UserID uint `json:"user_id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
var entries []entryRow
q := sc.DB.Table("sweepstake_entries AS e").
Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url").
Joins("JOIN users u ON u.id = e.user_id").
Joins("LEFT JOIN user_profiles up ON up.user_id = u.id").
Where("e.sweepstake_id = ?", id)
_ = q.Scan(&entries).Error
c.JSON(http.StatusOK, gin.H{ "sweepstake": s, "entries": entries, "winners": winners })
id := strings.TrimSpace(c.Param("id"))
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
now := time.Now()
if s.VisibilityUntil == nil || now.After(*s.VisibilityUntil) || now.Before(s.EndAt) {
c.JSON(http.StatusNotFound, gin.H{"error":"Not available"}); return
}
var winners []struct{ UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"` }
_ = sc.DB.Table("sweepstake_winners").Select("user_id, prize_name").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
type entryRow struct {
UserID uint `json:"user_id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
var entries []entryRow
q := sc.DB.Table("sweepstake_entries AS e").
Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url").
Joins("JOIN users u ON u.id = e.user_id").
Joins("LEFT JOIN user_profiles up ON up.user_id = u.id").
Where("e.sweepstake_id = ?", id)
_ = q.Scan(&entries).Error
c.JSON(http.StatusOK, gin.H{ "sweepstake": s, "entries": entries, "winners": winners })
}
// Admin: set or change prize for a specific winner
@@ -234,169 +238,187 @@ func (sc *SweepstakesController) AdminVisualData(c *gin.Context) {
})
}
func NewSweepstakesController(db *gorm.DB, es email.EmailService) *SweepstakesController {
return &SweepstakesController{DB: db, Email: es}
}
// Public: get current visible sweepstake (upcoming/active/finalized within visibility window)
func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
now := time.Now()
var s models.Sweepstake
q := sc.DB.Where("start_at <= ? AND (visibility_until IS NULL OR visibility_until >= ?)", now, now).
Order("start_at DESC")
if err := q.First(&s).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"sweepstake": nil})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load sweepstake"})
return
}
// Compute state
state := "upcoming"
if now.After(s.StartAt) && now.Before(s.EndAt) {
state = "active"
} else if now.After(s.EndAt) {
state = "finalized"
}
// Load prizes (public info)
var prizes []models.SweepstakePrize
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Order("display_order ASC, id ASC").Find(&prizes).Error
// Winners if selected
var winners []models.SweepstakeWinner
if s.WinnersSelectedAt != nil {
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Find(&winners).Error
}
// Current user status
hasEntered := false
visualPlayedAt := (*time.Time)(nil)
if uid, ok := c.Get("userID"); ok && uid != nil {
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).First(&e).Error; err == nil {
hasEntered = true
visualPlayedAt = e.VisualPlayedAt
}
}
c.JSON(http.StatusOK, gin.H{
"sweepstake": s,
"prizes": prizes,
"winners": winners,
"state": state,
"has_entered": hasEntered,
"visual_played_at": visualPlayedAt,
})
}
// Protected: enter/join sweepstake (idempotent)
func (sc *SweepstakesController) Enter(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
// Load sweepstake
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil {
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return
}
now := time.Now()
if !(now.After(s.StartAt) && now.Before(s.EndAt)) {
c.JSON(http.StatusBadRequest, gin.H{"error":"Soutěž není aktivní"}); return
}
// Idempotent create
entry := models.SweepstakeEntry{ SweepstakeID: s.ID, UserID: userID, Status: "valid" }
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, userID).FirstOrCreate(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: mark visualization as played (only once)
func (sc *SweepstakesController) MarkVisualPlayed(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
now := time.Now()
if !(now.After(s.EndAt) && (s.VisibilityUntil == nil || now.Before(*s.VisibilityUntil))) {
c.JSON(http.StatusBadRequest, gin.H{"error":"Vizualizace není dostupná"}); return
}
_ = sc.DB.Model(&models.SweepstakeEntry{}).
Where("sweepstake_id = ? AND user_id = ? AND visual_played_at IS NULL", s.ID, userID).
Updates(map[string]interface{}{"visual_played_at": time.Now(), "view_count": gorm.Expr("view_count + 1")}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: my wins list
func (sc *SweepstakesController) MyWinnings(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var wins []models.SweepstakeWinner
if err := sc.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&wins).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return
}
c.JSON(http.StatusOK, gin.H{"items": wins})
}
// Admin: list sweepstakes
// Admin: list sweepstakes with optional status filter
func (sc *SweepstakesController) AdminList(c *gin.Context) {
var items []models.Sweepstake
q := sc.DB.Model(&models.Sweepstake{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if err := q.Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"items": items})
status := strings.TrimSpace(c.Query("status"))
var items []models.Sweepstake
q := sc.DB.Model(&models.Sweepstake{}).Order("start_at DESC, id DESC")
if status != "" { q = q.Where("status = ?", status) }
if err := q.Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: create sweepstake
func (sc *SweepstakesController) AdminCreate(c *gin.Context) {
var body struct{
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
RulesURL string `json:"rules_url"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
PickerStyle string `json:"picker_style"`
TotalPrizes int `json:"total_prizes"`
PrizeSummary string `json:"prize_summary"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Title) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
}
item := models.Sweepstake{
Title: strings.TrimSpace(body.Title),
Description: strings.TrimSpace(body.Description),
ImageURL: strings.TrimSpace(body.ImageURL),
RulesURL: strings.TrimSpace(body.RulesURL),
StartAt: body.StartAt, EndAt: body.EndAt,
PickerStyle: ifEmpty(body.PickerStyle, "wheel"),
TotalPrizes: ifZero(body.TotalPrizes, 1),
PrizeSummary: strings.TrimSpace(body.PrizeSummary),
Status: "scheduled",
}
if time.Now().After(item.StartAt) { item.Status = "active" }
if err := sc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create"}); return }
c.JSON(http.StatusOK, item)
var body struct{
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
RulesURL string `json:"rules_url"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
PickerStyle string `json:"picker_style"`
TotalPrizes int `json:"total_prizes"`
PrizeSummary string `json:"prize_summary"`
EntryCostPoints int `json:"entry_cost_points"`
EntryFeeCZK float64 `json:"entry_fee_czk"`
MaxEntriesPerUser int `json:"max_entries_per_user"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Title) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
}
item := models.Sweepstake{
Title: strings.TrimSpace(body.Title),
Description: strings.TrimSpace(body.Description),
ImageURL: strings.TrimSpace(body.ImageURL),
RulesURL: strings.TrimSpace(body.RulesURL),
StartAt: body.StartAt, EndAt: body.EndAt,
PickerStyle: ifEmpty(body.PickerStyle, "wheel"),
TotalPrizes: func(v int) int { if v < 1 { return 1 }; if v > 100 { return 100 }; return v }(ifZero(body.TotalPrizes, 1)),
PrizeSummary: strings.TrimSpace(body.PrizeSummary),
EntryCostPoints: func(v int) int { if v < 0 { return 0 }; return v }(body.EntryCostPoints),
EntryFeeCZK: func(v float64) float64 { if v < 0 { return 0 }; return v }(body.EntryFeeCZK),
MaxEntriesPerUser: func(v int) int { if v <= 0 { return 1 }; return v }(body.MaxEntriesPerUser),
Status: "scheduled",
}
if time.Now().After(item.StartAt) { item.Status = "active" }
if err := sc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create"}); return }
c.JSON(http.StatusOK, item)
}
// Admin: update sweepstake
func (sc *SweepstakesController) AdminUpdate(c *gin.Context) {
id := c.Param("id")
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
allowed := map[string]bool{"title":true,"description":true,"image_url":true,"rules_url":true,"start_at":true,"end_at":true,"picker_style":true,"total_prizes":true,"prize_summary":true,"status":true}
upd := map[string]interface{}{}
for k,v := range body { if allowed[k] { upd[k] = v } }
if len(upd)==0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if err := sc.DB.Model(&models.Sweepstake{}).Where("id = ?", id).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
id := c.Param("id")
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
allowed := map[string]bool{"title":true,"description":true,"image_url":true,"rules_url":true,"start_at":true,"end_at":true,"picker_style":true,"total_prizes":true,"prize_summary":true,"status":true,"entry_cost_points":true,"entry_fee_czk":true,"max_entries_per_user":true}
upd := map[string]interface{}{}
for k,v := range body { if allowed[k] { upd[k] = v } }
if len(upd)==0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
// Clamp total_prizes if provided
if v, ok := upd["total_prizes"]; ok {
// Coerce to integer first
vv := 1
switch t := v.(type) {
case int:
vv = t
case int64:
vv = int(t)
case float64:
vv = int(t)
case string:
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { vv = n }
default:
// leave default 1
}
if vv < 1 { vv = 1 }
if vv > 100 { vv = 100 }
upd["total_prizes"] = vv
}
if err := sc.DB.Model(&models.Sweepstake{}).Where("id = ?", id).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: delete sweepstake
func (sc *SweepstakesController) AdminDelete(c *gin.Context) {
id := c.Param("id")
if err := sc.DB.Delete(&models.Sweepstake{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
id := c.Param("id")
if err := sc.DB.Delete(&models.Sweepstake{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: enter sweepstake (deduct points if needed, enforce max entries)
func (sc *SweepstakesController) Enter(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }; c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return }
now := time.Now()
if !(now.After(s.StartAt) && now.Before(s.EndAt)) { c.JSON(http.StatusBadRequest, gin.H{"error":"Soutěž není aktivní"}); return }
maxPerUser := s.MaxEntriesPerUser
if maxPerUser <= 0 { maxPerUser = 1 }
var existingCount int64
if err := sc.DB.Model(&models.SweepstakeEntry{}).Where("sweepstake_id = ? AND user_id = ? AND status = ?", s.ID, userID, "valid").Count(&existingCount).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to check entries"}); return }
if existingCount >= int64(maxPerUser) { c.JSON(http.StatusBadRequest, gin.H{"error":"Dosáhli jste limitu účastí v této soutěži"}); return }
costPoints := s.EntryCostPoints
if costPoints < 0 { costPoints = 0 }
if costPoints > 0 {
svc := services.NewEngagementService(sc.DB)
up, err := svc.EnsureProfile(userID)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze načíst profil"}); return }
if up.Points < int64(costPoints) { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Nemáte dostatek bodů (potřeba: %d)", costPoints)}); return }
if _, err := svc.AwardPointsAndXP(userID, -int64(costPoints), 0, "sweepstake_entry", map[string]interface{}{"sweepstake_id": s.ID}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze odečíst body"}); return }
e := models.SweepstakeEntry{ SweepstakeID: s.ID, UserID: userID, Status: "valid" }
if err := sc.DB.Create(&e).Error; err != nil { _, _ = svc.AwardPointsAndXP(userID, int64(costPoints), 0, "sweepstake_entry_refund", map[string]interface{}{"sweepstake_id": s.ID}); c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze vytvořit účast"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
entry := models.SweepstakeEntry{ SweepstakeID: s.ID, UserID: userID, Status: "valid" }
if existingCount == 0 {
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, userID).FirstOrCreate(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return }
} else {
if err := sc.DB.Create(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return }
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: mark visual played time for current user's entry
func (sc *SweepstakesController) MarkVisualPlayed(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
now := time.Now()
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", id, userID).Order("id ASC").First(&e).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Entry not found"}); return }
_ = sc.DB.Model(&models.SweepstakeEntry{}).Where("id = ?", e.ID).Update("visual_played_at", &now).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: list my winnings
func (sc *SweepstakesController) MyWinnings(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var items []models.SweepstakeWinner
if err := sc.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Public: get current visible sweepstake (upcoming/active/finalized within visibility window)
func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
now := time.Now()
var s models.Sweepstake
q := sc.DB.Where("start_at <= ? AND (visibility_until IS NULL OR visibility_until >= ?)", now, now).Order("start_at DESC")
if err := q.First(&s).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"sweepstake": nil})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load sweepstake"})
return
}
state := "upcoming"
if now.After(s.StartAt) && now.Before(s.EndAt) { state = "active" } else if now.After(s.EndAt) { state = "finalized" }
var prizes []models.SweepstakePrize
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Order("display_order ASC, id ASC").Find(&prizes).Error
var winners []models.SweepstakeWinner
if s.WinnersSelectedAt != nil { _ = sc.DB.Where("sweepstake_id = ?", s.ID).Find(&winners).Error }
hasEntered := false
visualPlayedAt := (*time.Time)(nil)
if uid, ok := c.Get("userID"); ok && uid != nil {
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).First(&e).Error; err == nil {
hasEntered = true
visualPlayedAt = e.VisualPlayedAt
}
}
c.JSON(http.StatusOK, gin.H{
"sweepstake": s,
"prizes": prizes,
"winners": winners,
"state": state,
"has_entered": hasEntered,
"visual_played_at": visualPlayedAt,
})
}
// Admin: list entries