mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
dev day #89
This commit is contained in:
@@ -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
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user