mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #89
This commit is contained in:
@@ -76,7 +76,10 @@ type Config struct {
|
||||
UmamiURL string
|
||||
UmamiUsername string
|
||||
UmamiPassword string
|
||||
UmamiWebsiteID string // If empty, will auto-create on production
|
||||
UmamiWebsiteID string
|
||||
|
||||
ErrorIngestURL string
|
||||
ErrorIngestToken string
|
||||
|
||||
// Antivirus (optional)
|
||||
ClamAVEnabled bool
|
||||
@@ -182,6 +185,9 @@ func LoadConfig() {
|
||||
UmamiPassword: getEnv("UMAMI_PASSWORD", ""),
|
||||
UmamiWebsiteID: getEnv("UMAMI_WEBSITE_ID", ""),
|
||||
|
||||
ErrorIngestURL: getEnv("ERROR_INGEST_URL", ""),
|
||||
ErrorIngestToken: getEnv("ERROR_INGEST_TOKEN", ""),
|
||||
|
||||
// Antivirus (optional)
|
||||
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
|
||||
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,55 @@ func JWTAuth(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// JWTOptional attempts to authenticate the request if a token or auth cookie is present.
|
||||
func JWTOptional(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
|
||||
if strings.ToLower(c.GetHeader("X-Dev-Admin")) == "true" {
|
||||
c.Set("userRole", "admin")
|
||||
c.Set("user", &models.User{Role: "admin"})
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
var tokenString string
|
||||
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
tokenString = parts[1]
|
||||
}
|
||||
}
|
||||
if tokenString == "" {
|
||||
if cookie, err := c.Request.Cookie("auth_token"); err == nil {
|
||||
tokenString = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
claims, err := utils.ParseJWT(tokenString)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, claims.UserID).Error; err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", &user)
|
||||
c.Set("claims", claims)
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("userRole", user.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// DevBypass checks for special dev header and grants admin role when not in production
|
||||
func DevBypass() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,14 @@ func CSRFProtection() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Dev-only: skip CSRF when using X-Admin-Token (remote admin tools)
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
|
||||
if token := c.GetHeader("X-Admin-Token"); token != "" && token == config.AppConfig.AdminAccessToken {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get token from header or form
|
||||
token := c.GetHeader("X-CSRF-Token")
|
||||
if token == "" {
|
||||
|
||||
@@ -15,10 +15,10 @@ func DBContext() gin.HandlerFunc {
|
||||
// 15 seconds is generous for most queries while preventing indefinite hangs
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
// Store the context so controllers can use it with db.WithContext(ctx)
|
||||
c.Set("dbCtx", ctx)
|
||||
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ErrorStatusReporter(reporter *services.ErrorReporter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
if reporter == nil {
|
||||
return
|
||||
}
|
||||
status := c.Writer.Status()
|
||||
if status >= 500 {
|
||||
msg := ""
|
||||
if len(c.Errors) > 0 {
|
||||
var parts []string
|
||||
for _, e := range c.Errors {
|
||||
if e != nil && e.Err != nil {
|
||||
parts = append(parts, e.Err.Error())
|
||||
} else if e != nil {
|
||||
parts = append(parts, e.Error())
|
||||
}
|
||||
}
|
||||
msg = strings.Join(parts, "; ")
|
||||
}
|
||||
reporter.Report(c.Request.Context(), &services.ErrorEvent{
|
||||
Origin: "backend",
|
||||
Language: "go",
|
||||
Severity: "error",
|
||||
Message: msg,
|
||||
URL: c.Request.URL.Path,
|
||||
Method: c.Request.Method,
|
||||
Status: status,
|
||||
RequestID: GetRequestID(c),
|
||||
})
|
||||
logger.Error("Reported 5xx status=%d path=%s", status, c.Request.URL.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"runtime/debug"
|
||||
|
||||
"fotbal-club/pkg/logger"
|
||||
"fotbal-club/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -41,3 +42,40 @@ func CustomRecovery() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CustomRecoveryWithReporter(reporter *services.ErrorReporter) gin.HandlerFunc {
|
||||
if reporter == nil {
|
||||
return CustomRecovery()
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
stack := string(debug.Stack())
|
||||
requestID := GetRequestID(c)
|
||||
logger.Error("Panic recovered",
|
||||
"request_id", requestID,
|
||||
"error", fmt.Sprintf("%v", err),
|
||||
"stack", stack,
|
||||
"path", c.Request.URL.Path,
|
||||
"method", c.Request.Method,
|
||||
)
|
||||
reporter.Report(c.Request.Context(), &services.ErrorEvent{
|
||||
Origin: "backend",
|
||||
Language: "go",
|
||||
Severity: "fatal",
|
||||
Message: fmt.Sprintf("%v", err),
|
||||
Stack: stack,
|
||||
URL: c.Request.URL.Path,
|
||||
Method: c.Request.Method,
|
||||
RequestID: requestID,
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Internal server error",
|
||||
"request_id": requestID,
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,18 @@ func ValidateContentType() gin.HandlerFunc {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Allow multipart for uploads and image processing crop upload
|
||||
if strings.Contains(path, "/upload") || strings.Contains(path, "/image-processing/crop-upload") {
|
||||
if strings.Contains(path, "/upload") || strings.Contains(path, "/image-processing/crop-upload") || strings.Contains(path, "/admin/scoreboard/qr") || strings.Contains(path, "/admin/scoreboard/load") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Allow scoreboard timer control endpoints without requiring JSON body
|
||||
// These actions do not read request body and are triggered via simple POSTs from remote UI
|
||||
if strings.Contains(path, "/admin/scoreboard/timer/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if strings.Contains(path, "/admin/scoreboard/swap-sides") || strings.Contains(path, "/admin/scoreboard/second-half") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type ErrorEvent struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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 datatypes.JSON `json:"tags" gorm:"type:jsonb"`
|
||||
Context datatypes.JSON `json:"context" gorm:"type:jsonb"`
|
||||
Env string `json:"env"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
+130
-63
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -19,38 +20,84 @@ type User struct {
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
}
|
||||
|
||||
// LoadVideosOverrides hydrates the in-memory VideosOverrides slice from the persisted JSON column.
|
||||
func (s *Settings) LoadVideosOverrides() {
|
||||
if s.VideosOverridesJSON == "" {
|
||||
s.VideosOverrides = nil
|
||||
return
|
||||
}
|
||||
var items []VideoTitleOverride
|
||||
if err := json.Unmarshal([]byte(s.VideosOverridesJSON), &items); err != nil {
|
||||
s.VideosOverrides = nil
|
||||
return
|
||||
}
|
||||
s.VideosOverrides = items
|
||||
}
|
||||
|
||||
// SetVideosOverrides stores provided overrides and updates serialized JSON column.
|
||||
func (s *Settings) SetVideosOverrides(items []VideoTitleOverride) error {
|
||||
s.VideosOverrides = items
|
||||
if len(items) == 0 {
|
||||
s.VideosOverridesJSON = ""
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.VideosOverridesJSON = string(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Settings) LoadVideosTitleOverrides() {
|
||||
if s.VideosOverrides == nil && s.VideosOverridesJSON != "" {
|
||||
s.LoadVideosOverrides()
|
||||
}
|
||||
m := map[string]string{}
|
||||
if len(s.VideosOverrides) > 0 {
|
||||
for _, it := range s.VideosOverrides {
|
||||
vid := strings.TrimSpace(it.VideoID)
|
||||
t := strings.TrimSpace(it.Title)
|
||||
if vid != "" && t != "" {
|
||||
m[vid] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
s.VideosTitleOverrides = m
|
||||
}
|
||||
|
||||
// Article represents a blog article
|
||||
type Article struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
AuthorID *uint `gorm:"index" json:"author_id,omitempty"`
|
||||
Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
|
||||
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
|
||||
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Published bool `gorm:"default:false" json:"published"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
Slug string `gorm:"uniqueIndex" json:"slug"`
|
||||
Excerpt string `gorm:"type:text" json:"excerpt"`
|
||||
Featured bool `gorm:"default:false;index" json:"featured"`
|
||||
// Fields for SEO and social previews
|
||||
SEOTitle string `json:"seo_title"`
|
||||
SEODescription string `gorm:"type:text" json:"seo_description"`
|
||||
// OG image for social sharing (optional)
|
||||
OGImageURL string `json:"og_image_url"`
|
||||
// Optional: link to external content or embedded media
|
||||
ExternalLink string `json:"external_link"`
|
||||
ViewCount int `gorm:"default:0;index" json:"view_count"`
|
||||
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
|
||||
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
|
||||
// Store the category name directly to simplify queries (denormalized)
|
||||
CategoryName string `json:"category_name"`
|
||||
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
|
||||
// Gallery association (optional)
|
||||
GalleryAlbumID string `json:"gallery_album_id"`
|
||||
GalleryAlbumURL string `json:"gallery_album_url"`
|
||||
// Stored as JSON string or comma-separated list; frontend normalizes
|
||||
gorm.Model
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
AuthorID *uint `gorm:"index" json:"author_id,omitempty"`
|
||||
Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
|
||||
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
|
||||
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Published bool `gorm:"default:false" json:"published"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
Slug string `gorm:"uniqueIndex" json:"slug"`
|
||||
Excerpt string `gorm:"type:text" json:"excerpt"`
|
||||
Featured bool `gorm:"default:false;index" json:"featured"`
|
||||
// Fields for SEO and social previews
|
||||
SEOTitle string `json:"seo_title"`
|
||||
SEODescription string `gorm:"type:text" json:"seo_description"`
|
||||
// OG image for social sharing (optional)
|
||||
OGImageURL string `json:"og_image_url"`
|
||||
// Optional: link to external content or embedded media
|
||||
ExternalLink string `json:"external_link"`
|
||||
ViewCount int `gorm:"default:0;index" json:"view_count"`
|
||||
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
|
||||
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
|
||||
// Store the category name directly to simplify queries (denormalized)
|
||||
CategoryName string `json:"category_name"`
|
||||
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
|
||||
// Gallery association (optional)
|
||||
GalleryAlbumID string `json:"gallery_album_id"`
|
||||
GalleryAlbumURL string `json:"gallery_album_url"`
|
||||
// Stored as JSON string or comma-separated list; frontend normalizes
|
||||
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
|
||||
// YouTube video association (optional)
|
||||
YouTubeVideoID string `json:"youtube_video_id"`
|
||||
@@ -80,43 +127,43 @@ func (ArticleTeamLink) TableName() string { return "article_team_links" }
|
||||
|
||||
// ArticleMatchLink represents a link from an article to a match identified by an external FACR match ID
|
||||
type ArticleMatchLink struct {
|
||||
gorm.Model
|
||||
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||
ExternalMatchID string `gorm:"not null;index" json:"external_match_id"`
|
||||
Title string `json:"title"`
|
||||
gorm.Model
|
||||
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||
ExternalMatchID string `gorm:"not null;index" json:"external_match_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (ArticleMatchLink) TableName() string { return "article_match_links" }
|
||||
|
||||
// Team represents a football team
|
||||
type Team struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"not null"`
|
||||
ShortName string
|
||||
Description string
|
||||
LogoURL string `json:"logo_url"`
|
||||
IsActive bool `gorm:"default:true"`
|
||||
gorm.Model
|
||||
Name string `gorm:"not null"`
|
||||
ShortName string
|
||||
Description string
|
||||
LogoURL string `json:"logo_url"`
|
||||
IsActive bool `gorm:"default:true"`
|
||||
}
|
||||
|
||||
// Player represents a football player
|
||||
type Player struct {
|
||||
gorm.Model
|
||||
FirstName string `gorm:"not null" json:"first_name"`
|
||||
LastName string `gorm:"not null" json:"last_name"`
|
||||
DateOfBirth time.Time `json:"date_of_birth"`
|
||||
Position string `json:"position"`
|
||||
JerseyNumber int `json:"jersey_number"`
|
||||
TeamID uint `json:"team_id"`
|
||||
Team Team `gorm:"foreignKey:TeamID" json:"team"`
|
||||
Nationality string `json:"nationality"`
|
||||
Height int `json:"height"` // in cm
|
||||
Weight int `json:"weight"` // in kg
|
||||
ImageURL string `json:"image_url"`
|
||||
Gender string `json:"gender"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
gorm.Model
|
||||
FirstName string `gorm:"not null" json:"first_name"`
|
||||
LastName string `gorm:"not null" json:"last_name"`
|
||||
DateOfBirth time.Time `json:"date_of_birth"`
|
||||
Position string `json:"position"`
|
||||
JerseyNumber int `json:"jersey_number"`
|
||||
TeamID uint `json:"team_id"`
|
||||
Team Team `gorm:"foreignKey:TeamID" json:"team"`
|
||||
Nationality string `json:"nationality"`
|
||||
Height int `json:"height"` // in cm
|
||||
Weight int `json:"weight"` // in kg
|
||||
ImageURL string `json:"image_url"`
|
||||
Gender string `json:"gender"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
// Sponsor represents a sponsor
|
||||
@@ -135,6 +182,19 @@ type Sponsor struct {
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// VideoTitleOverride represents a per-video title override (for auto YouTube source)
|
||||
type VideoTitleOverride struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// CustomNavLink represents a simple custom navigation link stored in settings.custom_nav
|
||||
type CustomNavLink struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
External bool `json:"external"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
gorm.Model
|
||||
// Frontpage layout and style variants (e.g., "classic", "grid"; "light", "dark")
|
||||
@@ -218,6 +278,12 @@ type Settings struct {
|
||||
VideosJSON string `gorm:"type:text" json:"-"`
|
||||
VideosItemsJSON string `gorm:"type:text" json:"-"`
|
||||
|
||||
// Title overrides for auto-fetched videos (stored as JSON array of {video_id,title})
|
||||
VideosOverridesJSON string `gorm:"type:text" json:"-"`
|
||||
VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"`
|
||||
// Derived helper for API responses (map form used by frontend/admin): video_id -> title
|
||||
VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"`
|
||||
|
||||
// Merch module configuration
|
||||
MerchModuleEnabled bool `json:"merch_module_enabled"`
|
||||
MerchStyle string `json:"merch_style"` // grid | slider (future)
|
||||
@@ -267,17 +333,18 @@ type Settings struct {
|
||||
StorageQuotaMB int `json:"storage_quota_mb"`
|
||||
StorageWarnThreshold int `json:"storage_warn_threshold"`
|
||||
StorageCriticalThreshold int `json:"storage_critical_threshold"`
|
||||
|
||||
// External error-review integration
|
||||
ErrorReviewIngestURL string `json:"error_review_ingest_url"`
|
||||
ErrorReviewIngestToken string `json:"error_review_ingest_token"`
|
||||
ErrorReviewAdminURL string `json:"error_review_admin_url"`
|
||||
ErrorReviewAdminToken string `json:"error_review_admin_token"`
|
||||
ErrorReviewUIURL string `json:"error_review_ui_url"`
|
||||
}
|
||||
|
||||
// TableName specifies table name for Settings model
|
||||
func (Settings) TableName() string { return "settings" }
|
||||
|
||||
// CustomNavLink represents an item in the main navigation managed via settings
|
||||
type CustomNavLink struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
External bool `json:"external"`
|
||||
}
|
||||
|
||||
// LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string.
|
||||
func (s *Settings) LoadCustomNav() {
|
||||
|
||||
@@ -84,7 +84,6 @@ func (n *NavigationItem) GetURL() string {
|
||||
"scoreboard": "/admin/scoreboard",
|
||||
"scoreboard_remote": "/admin/scoreboard/remote",
|
||||
"clothing": "/admin/obleceni",
|
||||
"media": "/admin/media",
|
||||
"sponsors": "/admin/sponzori",
|
||||
"banners": "/admin/bannery",
|
||||
"messages": "/admin/zpravy",
|
||||
|
||||
@@ -16,6 +16,8 @@ type Sweepstake struct {
|
||||
PickerStyle string `json:"picker_style" gorm:"size:16;not null;default:'wheel'"` // wheel|cycler
|
||||
TotalPrizes int `json:"total_prizes" gorm:"not null;default:1"`
|
||||
PrizeSummary string `json:"prize_summary" gorm:"type:text"`
|
||||
EntryCostPoints int `json:"entry_cost_points" gorm:"not null;default:0"`
|
||||
EntryFeeCZK float64 `json:"entry_fee_czk" gorm:"not null;default:0"`
|
||||
WinnersSelectedAt *time.Time `json:"winners_selected_at"`
|
||||
VisibilityUntil *time.Time `json:"visibility_until"`
|
||||
DrawSeed string `json:"draw_seed" gorm:"size:64"`
|
||||
|
||||
@@ -58,6 +58,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
youtubeController := controllers.NewYouTubeController(db)
|
||||
umamiController := controllers.NewUmamiController()
|
||||
imageProcessingController := &controllers.ImageProcessingController{}
|
||||
errorController := controllers.NewErrorController(db)
|
||||
|
||||
// API v1 group
|
||||
{
|
||||
@@ -95,6 +96,8 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// SMTP validation (public during setup; does not send email, only connects)
|
||||
api.POST("/setup/validate-smtp", baseController.ValidateSMTP)
|
||||
|
||||
api.POST("/errors", middleware.RateLimit(120, time.Minute), errorController.Ingest)
|
||||
|
||||
// Auth routes
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
@@ -122,14 +125,15 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// Event routes (public)
|
||||
eventController := &controllers.EventController{DB: db}
|
||||
events := api.Group("/events")
|
||||
events.Use(middleware.JWTOptional(db))
|
||||
{
|
||||
events.GET("", eventController.GetEvents)
|
||||
events.GET("/upcoming", eventController.GetUpcomingEvents)
|
||||
events.GET("/:id", eventController.GetEventByID)
|
||||
}
|
||||
|
||||
// Comments (public list)
|
||||
api.GET("/comments", commentController.GetComments)
|
||||
// Comments (public list; attach optional auth to personalize reactions)
|
||||
api.GET("/comments", middleware.JWTOptional(db), commentController.GetComments)
|
||||
|
||||
// Engagement (public + protected)
|
||||
api.GET("/engagement/rewards", engagementController.GetRewards)
|
||||
@@ -271,6 +275,13 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
admin := protected.Group("/admin")
|
||||
admin.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
// Errors
|
||||
admin.GET("/errors", errorController.AdminList)
|
||||
admin.GET("/errors/:id", errorController.AdminGet)
|
||||
// External Error Review proxies
|
||||
admin.GET("/errors/external", errorController.AdminListExternal)
|
||||
admin.GET("/errors/external/:id", errorController.AdminGetExternal)
|
||||
|
||||
// Comments
|
||||
commentsAdmin := admin.Group("/comments")
|
||||
{
|
||||
@@ -321,6 +332,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// Scoreboard sponsors & QR (admin-only)
|
||||
admin.GET("/scoreboard/sponsors", scoreboardController.ListSponsors)
|
||||
admin.POST("/scoreboard/sponsors/upload", scoreboardController.UploadSponsors)
|
||||
admin.POST("/scoreboard/sponsors/prefill", scoreboardController.PrefillSponsorsFromPage)
|
||||
admin.DELETE("/scoreboard/sponsors", scoreboardController.DeleteSponsor)
|
||||
admin.GET("/scoreboard/qr", scoreboardController.GetQR)
|
||||
admin.POST("/scoreboard/qr", scoreboardController.UploadQR)
|
||||
@@ -578,18 +590,25 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
|
||||
api.GET("/scoreboard", scoreboardController.GetPublic)
|
||||
api.GET("/scoreboard/colors/derive", scoreboardController.DeriveColors)
|
||||
api.GET("/scoreboard/sponsors", scoreboardController.ListSponsors)
|
||||
api.GET("/scoreboard/qr", scoreboardController.GetQR)
|
||||
|
||||
api.GET("/settings", baseController.GetPublicSettings)
|
||||
api.GET("/competition-aliases", baseController.GetPublicCompetitionAliases)
|
||||
api.GET("/public/team-logo-overrides", baseController.GetPublicTeamLogoOverrides)
|
||||
|
||||
api.GET("/articles/featured", baseController.GetFeaturedArticles)
|
||||
api.GET("/articles", baseController.GetArticles)
|
||||
api.GET("/articles/:id", baseController.GetArticle)
|
||||
api.GET("/articles/slug/:slug", baseController.GetArticleBySlug)
|
||||
api.POST("/articles/:id/read", baseController.IncrementArticleRead)
|
||||
api.POST("/articles/:id/track-view", baseController.TrackArticleView)
|
||||
api.GET("/articles/:id/match-link", baseController.GetArticleMatchLink)
|
||||
// Articles (public; use optional auth so admin/editor can see drafts in list when requesting published=false)
|
||||
articlesPub := api.Group("/articles")
|
||||
articlesPub.Use(middleware.JWTOptional(db))
|
||||
{
|
||||
articlesPub.GET("/featured", baseController.GetFeaturedArticles)
|
||||
articlesPub.GET("", baseController.GetArticles)
|
||||
articlesPub.GET("/slug/:slug", baseController.GetArticleBySlug)
|
||||
articlesPub.GET("/:id", baseController.GetArticle)
|
||||
articlesPub.POST("/:id/read", baseController.IncrementArticleRead)
|
||||
articlesPub.POST("/:id/track-view", baseController.TrackArticleView)
|
||||
articlesPub.GET("/:id/match-link", baseController.GetArticleMatchLink)
|
||||
}
|
||||
api.GET("/categories", baseController.GetCategories)
|
||||
api.GET("/youtube/videos", youtubeController.GetYouTubeVideos)
|
||||
api.GET("/about", aboutController.GetPublicAboutPage)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/pkg/httpclient"
|
||||
)
|
||||
|
||||
type ErrorEvent struct {
|
||||
Origin string `json:"origin"`
|
||||
Language string `json:"language"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Component string `json:"component,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Column int `json:"column,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
UserID *uint `json:"user_id,omitempty"`
|
||||
Session string `json:"session_token,omitempty"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
Context map[string]interface{} `json:"context,omitempty"`
|
||||
Env string `json:"env,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type ErrorReporter struct {
|
||||
client *http.Client
|
||||
endpoint string
|
||||
token string
|
||||
hostname string
|
||||
appEnv string
|
||||
}
|
||||
|
||||
func NewErrorReporter(cfg *config.Config) *ErrorReporter {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
endpoint := cfg.ErrorIngestURL
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
if cfg.AppEnv == "production" {
|
||||
endpoint = "https://errors.tdvorak.dev/api/v1/errors"
|
||||
} else {
|
||||
base := strings.TrimRight(cfg.PublicAPIBaseURL, "/")
|
||||
if base != "" {
|
||||
endpoint = base + "/errors"
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
return nil
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
return &ErrorReporter{
|
||||
client: httpclient.FastClient(),
|
||||
endpoint: endpoint,
|
||||
token: cfg.ErrorIngestToken,
|
||||
hostname: host,
|
||||
appEnv: cfg.AppEnv,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ErrorReporter) Report(ctx context.Context, ev *ErrorEvent) {
|
||||
if r == nil || ev == nil || r.endpoint == "" {
|
||||
return
|
||||
}
|
||||
if ev.Env == "" {
|
||||
ev.Env = r.appEnv
|
||||
}
|
||||
if ev.Hostname == "" {
|
||||
ev.Hostname = r.hostname
|
||||
}
|
||||
if ev.OccurredAt.IsZero() {
|
||||
ev.OccurredAt = time.Now()
|
||||
}
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx2, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx2, http.MethodPost, r.endpoint, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if r.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
}
|
||||
_, _ = r.client.Do(req)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type erMonitor struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CheckIntervalSec int `json:"check_interval_sec"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
ExpectStatusMin int `json:"expect_status_min"`
|
||||
ExpectStatusMax int `json:"expect_status_max"`
|
||||
}
|
||||
|
||||
type erListResp struct {
|
||||
Items []erMonitor `json:"items"`
|
||||
}
|
||||
|
||||
func StartErrorReviewAutoRegister(db *gorm.DB) {
|
||||
go func() {
|
||||
defer func() { _ = recover() }()
|
||||
time.Sleep(2 * time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
if tryAutoRegister(db) {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Duration(2+i) * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func tryAutoRegister(db *gorm.DB) bool {
|
||||
if db == nil || config.AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
var s models.Settings
|
||||
_ = db.First(&s).Error
|
||||
// Domain is managed via environment only; fallback based on ERROR_LOCAL
|
||||
adminURL := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
|
||||
if adminURL == "" {
|
||||
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 {
|
||||
adminURL = "http://127.0.0.1:8083/api/v1/admin"
|
||||
} else {
|
||||
adminURL = "https://error.tdvorak.dev/api/v1/admin"
|
||||
}
|
||||
}
|
||||
// Prefer env token only
|
||||
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
// Only register after setup is complete (club name present)
|
||||
if strings.TrimSpace(s.ClubName) == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(adminURL, "/") {
|
||||
adminURL = strings.TrimRight(adminURL, "/")
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(s.APIBaseURL), "/")
|
||||
if base == "" {
|
||||
base = strings.TrimRight(config.AppConfig.PublicAPIBaseURL, "/")
|
||||
}
|
||||
if base == "" {
|
||||
// Fallback for local dev
|
||||
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:8080/api/v1"
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Ensure API suffix present
|
||||
if !strings.Contains(base, "/api/") { base = strings.TrimRight(base, "/") + "/api/v1" }
|
||||
monURL := base + "/health"
|
||||
disp := "Fotbal Club"
|
||||
if strings.TrimSpace(s.ClubName) != "" { disp = strings.TrimSpace(s.ClubName) }
|
||||
host := ""
|
||||
if u, err := url.Parse(monURL); err == nil { host = u.Hostname() }
|
||||
name := disp
|
||||
if host != "" { name = name + " (" + host + ")" }
|
||||
name = name + " API Health"
|
||||
mon := erMonitor{
|
||||
Name: name,
|
||||
URL: monURL,
|
||||
Enabled: true,
|
||||
CheckIntervalSec: 60,
|
||||
TimeoutMs: 4000,
|
||||
ExpectStatusMin: 200,
|
||||
ExpectStatusMax: 399,
|
||||
}
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, adminURL+"/monitors", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("X-Admin-Token", token)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
var list erListResp
|
||||
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
|
||||
return false
|
||||
}
|
||||
var existing *erMonitor
|
||||
for i := range list.Items {
|
||||
if strings.TrimSpace(list.Items[i].URL) == monURL {
|
||||
existing = &list.Items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(mon)
|
||||
if existing == nil {
|
||||
req2, _ := http.NewRequest(http.MethodPost, adminURL+"/monitors", bytes.NewReader(b))
|
||||
req2.Header.Set("Authorization", "Bearer "+token)
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("X-Admin-Token", token)
|
||||
res2, err2 := client.Do(req2)
|
||||
if err2 != nil {
|
||||
return false
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode == http.StatusCreated || res2.StatusCode == http.StatusOK {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
req3, _ := http.NewRequest(http.MethodPut, adminURL+"/monitors/"+itoa(existing.ID), bytes.NewReader(b))
|
||||
req3.Header.Set("Authorization", "Bearer "+token)
|
||||
req3.Header.Set("Content-Type", "application/json")
|
||||
req3.Header.Set("X-Admin-Token", token)
|
||||
res3, err3 := client.Do(req3)
|
||||
if err3 != nil {
|
||||
return false
|
||||
}
|
||||
defer res3.Body.Close()
|
||||
if res3.StatusCode == http.StatusOK {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func itoa(u uint) string {
|
||||
return strconv.FormatUint(uint64(u), 10)
|
||||
}
|
||||
@@ -17,8 +17,7 @@ func EvaluateSpamScore(s string) (float64, []string) {
|
||||
rules = append(rules, "too_short")
|
||||
}
|
||||
// Excessive repeated characters like 'aaaaaa' or '!!!!'
|
||||
repeatRe := regexp.MustCompile(`([a-zA-Z!?.])\1{4,}`)
|
||||
if repeatRe.MatchString(content) {
|
||||
if hasExcessiveRepetition(content, 5) {
|
||||
rules = append(rules, "repeated_chars")
|
||||
}
|
||||
// Low vowel ratio suggests gibberish in Czech/English latin text
|
||||
@@ -53,3 +52,33 @@ func EvaluateSpamScore(s string) (float64, []string) {
|
||||
if score > 1.0 { score = 1.0 }
|
||||
return score, rules
|
||||
}
|
||||
|
||||
// hasExcessiveRepetition checks if s contains a run of the same character of length >= minRun
|
||||
// Limited to ASCII letters and the punctuation characters ! ? . to mirror the previous intent.
|
||||
func hasExcessiveRepetition(s string, minRun int) bool {
|
||||
if minRun < 2 { minRun = 2 }
|
||||
run := 1
|
||||
var prev rune
|
||||
first := true
|
||||
for _, r := range s {
|
||||
if first {
|
||||
prev = r
|
||||
first = false
|
||||
continue
|
||||
}
|
||||
if r == prev && (isAsciiLetter(r) || r == '!' || r == '?' || r == '.') {
|
||||
run++
|
||||
if run >= minRun {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
prev = r
|
||||
run = 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAsciiLetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
if nWinners == 0 {
|
||||
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes }
|
||||
}
|
||||
// Cap winners to a safe maximum
|
||||
if nWinners > 100 { nWinners = 100 }
|
||||
if nWinners > len(entries) { nWinners = len(entries) }
|
||||
|
||||
// Build seed
|
||||
|
||||
Reference in New Issue
Block a user