@FotbalKunovice) nebo URL kanálu a načtěte videa z karty „Videa“.
- Služba: https://youtube.tdvorak.dev/
- @FotbalKunovice) nebo URL kanálu a načtěte videa z karty „Videa“. Služba: youtube.tdvorak.dev
+ Děkujeme za přihlášení. Spravujte své preference zde.
", manageURL), - Recipients: []string{emailStr}, - }) // Recalculate automation after (re)subscription cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, gin.H{"message": "Subscribed"}) @@ -700,21 +716,89 @@ func (cc *ContactController) SubmitContactForm(c *gin.Context) { return } - go func(nm, em, subj, msgBody, ipAddr, agent string) { + go func(m models.ContactMessage) { + // 1) Notify primary contact(s) (club contact email / env fallbacks) _ = cc.emailService.SendContactForm(&email.ContactFormData{ - Name: nm, - Email: em, - Subject: subj, - Message: msgBody, - IPAddress: ipAddr, - UserAgent: agent, + Name: m.Name, + Email: m.Email, + Subject: m.Subject, + Message: m.Message, + IPAddress: m.IPAddress, + UserAgent: m.UserAgent, }) - }(name, emailStr, subject, message, ip, ua) + + // 2) Auto-forward to configured list when enabled + var set models.Settings + if err := cc.DB.First(&set).Error; err == nil && set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" { + // Build recipient list from ContactForwardList (comma/semicolon/space separated) + parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) + uniq := make(map[string]struct{}) + dest := make([]string, 0, len(parts)) + // Exclude addresses that already received the primary notification (contact/admin emails) + exclude := map[string]struct{}{} + if v := strings.ToLower(strings.TrimSpace(set.ContactEmail)); v != "" { + exclude[v] = struct{}{} + } + if config.AppConfig != nil { + if v := strings.ToLower(strings.TrimSpace(config.AppConfig.ContactEmail)); v != "" { + exclude[v] = struct{}{} + } + if v := strings.ToLower(strings.TrimSpace(config.AppConfig.AdminEmail)); v != "" { + exclude[v] = struct{}{} + } + } + for _, p := range parts { + v := strings.TrimSpace(p) + if v == "" { + continue + } + lv := strings.ToLower(v) + if _, ok := uniq[lv]; ok { + continue + } + if _, skip := exclude[lv]; skip { + continue + } + uniq[lv] = struct{}{} + dest = append(dest, v) + } + if len(dest) > 0 { + fwd := &email.EmailData{ + Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", strings.TrimSpace(m.Subject)), + To: dest, + Template: "contact_form", + Data: struct { + Name string + Email string + Subject string + Message string + Time string + IP string + Agent string + }{ + Name: m.Name, + Email: m.Email, + Subject: m.Subject, + Message: m.Message, + Time: m.CreatedAt.Format(time.RFC1123Z), + IP: m.IPAddress, + Agent: m.UserAgent, + }, + } + if err := cc.emailService.SendEmail(fwd); err != nil { + logger.Error("Auto-forward of contact message %d failed: %v", m.ID, err) + } else { + logger.Info("Auto-forwarded contact message %d to %v", m.ID, dest) + } + } + } + }(msg) c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID}) } func (cc *ContactController) AdminSmtpTest(c *gin.Context) { + // ... rest of the code remains the same ... if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return @@ -904,6 +988,33 @@ func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) { s = models.Settings{} } s.NewsletterEnabled = input.Enabled + + // If enabling, ensure defaults for weekly/matches/results are set like auto-recalc does + if input.Enabled { + if !s.EnableWeekly { + s.EnableWeekly = true + } + if strings.TrimSpace(s.NewsletterWeeklyDay) == "" { + s.NewsletterWeeklyDay = "sun" + } + if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 { + s.NewsletterWeeklyHour = 9 + } + if !s.EnableMatchReminders { + s.EnableMatchReminders = true + } + if s.NewsletterReminderLeadHours <= 0 { + s.NewsletterReminderLeadHours = 48 + } + if !s.EnableResults { + s.EnableResults = true + } + if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 { + s.NewsletterQuietStart = 22 + s.NewsletterQuietEnd = 8 + } + } + if s.ID == 0 { if err := cc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}) diff --git a/internal/controllers/engagement_controller.go b/internal/controllers/engagement_controller.go index acaed44..c447539 100644 --- a/internal/controllers/engagement_controller.go +++ b/internal/controllers/engagement_controller.go @@ -2,13 +2,13 @@ package controllers import ( "net/http" - "strings" "strconv" + "strings" "time" "github.com/gin-gonic/gin" - "gorm.io/gorm" "gorm.io/datatypes" + "gorm.io/gorm" "fotbal-club/internal/models" "fotbal-club/internal/services" @@ -23,61 +23,82 @@ type EngagementController struct { // parseMetaTime tries to parse time from metadata value which can be string (RFC3339 or YYYY-MM-DD) or numeric unix seconds. func parseMetaTime(v interface{}) time.Time { - switch t := v.(type) { - case string: - s := strings.TrimSpace(t) - if s == "" { return time.Time{} } - if ts, err := time.Parse(time.RFC3339, s); err == nil { return ts } - if ts, err := time.Parse("2006-01-02T15:04", s); err == nil { return ts } - if ts, err := time.Parse("2006-01-02", s); err == nil { return ts } - case float64: - // JSON numbers decode to float64 - if t <= 0 { return time.Time{} } - return time.Unix(int64(t), 0) - case int64: - if t <= 0 { return time.Time{} } - return time.Unix(t, 0) - case int: - if t <= 0 { return time.Time{} } - return time.Unix(int64(t), 0) - } - return time.Time{} + switch t := v.(type) { + case string: + s := strings.TrimSpace(t) + if s == "" { + return time.Time{} + } + if ts, err := time.Parse(time.RFC3339, s); err == nil { + return ts + } + if ts, err := time.Parse("2006-01-02T15:04", s); err == nil { + return ts + } + if ts, err := time.Parse("2006-01-02", s); err == nil { + return ts + } + case float64: + // JSON numbers decode to float64 + if t <= 0 { + return time.Time{} + } + return time.Unix(int64(t), 0) + case int64: + if t <= 0 { + return time.Time{} + } + return time.Unix(t, 0) + case int: + if t <= 0 { + return time.Time{} + } + return time.Unix(int64(t), 0) + } + return time.Time{} } func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController { - return &EngagementController{DB: db, Email: es} + return &EngagementController{DB: db, Email: es} } // Admin: adjust points for a user (positive or negative) // POST /api/v1/admin/engagement/adjust { user_id, delta, reason?, meta? } func (ec *EngagementController) AdminAdjustPoints(c *gin.Context) { var body struct { - UserID uint `json:"user_id"` - Delta int64 `json:"delta"` - Reason string `json:"reason"` - Meta map[string]interface{} `json:"meta"` - CurrentPassword string `json:"current_password"` + UserID uint `json:"user_id"` + Delta int64 `json:"delta"` + Reason string `json:"reason"` + Meta map[string]interface{} `json:"meta"` + CurrentPassword string `json:"current_password"` } if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 || body.Delta == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return } // Require admin password confirmation for any manual adjustment cu, ok := c.Get("user") if !ok || cu == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error":"not authenticated"}); return + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return } if strings.TrimSpace(body.CurrentPassword) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error":"current_password is required"}); return + c.JSON(http.StatusBadRequest, gin.H{"error": "current_password is required"}) + return } currentUser := cu.(*models.User) if err := utils.CheckPassword(body.CurrentPassword, currentUser.Password); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error":"invalid current password"}); return + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid current password"}) + return } reason := strings.TrimSpace(body.Reason) - if reason == "" { reason = "admin_adjust" } + if reason == "" { + reason = "admin_adjust" + } svc := services.NewEngagementService(ec.DB) if _, err := svc.AwardPoints(body.UserID, body.Delta, reason, body.Meta); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to adjust points"}); return + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to adjust points"}) + return } // Re-check achievements opportunistically _ = svc.CheckAndAwardAchievements(body.UserID) @@ -97,17 +118,17 @@ func (ec *EngagementController) GetProfile(c *gin.Context) { var achCount int64 _ = ec.DB.Model(&models.UserAchievement{}).Where("user_id = ?", userID).Count(&achCount).Error c.JSON(http.StatusOK, gin.H{ - "user_id": userID, - "points": up.Points, - "level": up.Level, - "xp": up.XP, - "username": up.Username, - "avatar_url": up.AvatarURL, - "animated_avatar_url": up.AnimatedAvatarURL, - "avatar_upload_unlocked": up.AvatarUploadUnlocked, + "user_id": userID, + "points": up.Points, + "level": up.Level, + "xp": up.XP, + "username": up.Username, + "avatar_url": up.AvatarURL, + "animated_avatar_url": up.AnimatedAvatarURL, + "avatar_upload_unlocked": up.AvatarUploadUnlocked, "animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked, - "achievements": achCount, - "engagement_disabled": c.GetString("userRole") == "admin", + "achievements": achCount, + "engagement_disabled": c.GetString("userRole") == "admin", }) } @@ -162,8 +183,8 @@ func (ec *EngagementController) PatchAvatar(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(uint) var body struct { - AvatarURL *string `json:"avatar_url"` - AnimatedAvatarURL *string `json:"animated_avatar_url"` + AvatarURL *string `json:"avatar_url"` + AnimatedAvatarURL *string `json:"animated_avatar_url"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) @@ -219,59 +240,73 @@ 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: 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 - } - // Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to. - now := time.Now() - filtered := make([]models.RewardItem, 0, len(items)) - for _, it := range items { - // Mandatory unlock reward is always available - if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") { - filtered = append(filtered, it) - continue - } - var startPtr, endPtr *time.Time - if it.Metadata != nil { - if v, ok := it.Metadata["valid_from"]; ok { - if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts } - } - if v, ok := it.Metadata["valid_to"]; ok { - if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts } - } else if v2, ok2 := it.Metadata["expires_at"]; ok2 { // alias - if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts } - } - } - if startPtr != nil && now.Before(*startPtr) { continue } - if endPtr != nil && now.After(*endPtr) { continue } - filtered = append(filtered, it) - } - c.JSON(http.StatusOK, filtered) + 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 + } + // Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to. + now := time.Now() + filtered := make([]models.RewardItem, 0, len(items)) + for _, it := range items { + // Mandatory unlock reward is always available + if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") { + filtered = append(filtered, it) + continue + } + var startPtr, endPtr *time.Time + if it.Metadata != nil { + if v, ok := it.Metadata["valid_from"]; ok { + if ts := parseMetaTime(v); !ts.IsZero() { + startPtr = &ts + } + } + if v, ok := it.Metadata["valid_to"]; ok { + if ts := parseMetaTime(v); !ts.IsZero() { + endPtr = &ts + } + } else if v2, ok2 := it.Metadata["expires_at"]; ok2 { // alias + if ts := parseMetaTime(v2); !ts.IsZero() { + endPtr = &ts + } + } + } + if startPtr != nil && now.Before(*startPtr) { + continue + } + if endPtr != nil && now.After(*endPtr) { + continue + } + filtered = append(filtered, it) + } + c.JSON(http.StatusOK, filtered) } // POST /api/v1/engagement/redeem (auth) @@ -303,12 +338,18 @@ func (ec *EngagementController) Redeem(c *gin.Context) { if item.Metadata != nil { var startPtr, endPtr *time.Time if v, ok := item.Metadata["valid_from"]; ok { - if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts } + if ts := parseMetaTime(v); !ts.IsZero() { + startPtr = &ts + } } if v, ok := item.Metadata["valid_to"]; ok { - if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts } + if ts := parseMetaTime(v); !ts.IsZero() { + endPtr = &ts + } } else if v2, ok2 := item.Metadata["expires_at"]; ok2 { - if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts } + if ts := parseMetaTime(v2); !ts.IsZero() { + endPtr = &ts + } } now := time.Now() if startPtr != nil && now.Before(*startPtr) { @@ -356,9 +397,9 @@ func (ec *EngagementController) Redeem(c *gin.Context) { } } red := models.RewardRedemption{ - UserID: userID, - RewardID: item.ID, - Status: "approved", + UserID: userID, + RewardID: item.ID, + Status: "approved", } if strings.HasPrefix(item.Type, "merch_") || item.Type == "custom" { red.Status = "pending" @@ -368,7 +409,7 @@ func (ec *EngagementController) Redeem(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create redemption"}) return } - _ = tx.Create(&models.PointsTransaction{ UserID: userID, Delta: -item.CostPoints, XPDelta: 0, Reason: "redeem", Meta: datatypes.JSONMap{"reward_id": item.ID, "reward_type": item.Type} }).Error + _ = tx.Create(&models.PointsTransaction{UserID: userID, Delta: -item.CostPoints, XPDelta: 0, Reason: "redeem", Meta: datatypes.JSONMap{"reward_id": item.ID, "reward_type": item.Type}}).Error if item.Type == "avatar_static" { _ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("avatar_url", item.ImageURL).Error } @@ -396,11 +437,11 @@ func (ec *EngagementController) Redeem(c *gin.Context) { To: []string{strings.TrimSpace(user.Email)}, Template: "reward_redeemed_user", Data: map[string]interface{}{ - "RewardName": item.Name, - "RewardType": item.Type, - "Points": item.CostPoints, - "Status": red.Status, - "RedeemedAt": redeemedAt, + "RewardName": item.Name, + "RewardType": item.Type, + "Points": item.CostPoints, + "Status": red.Status, + "RedeemedAt": redeemedAt, "UserFirstName": strings.TrimSpace(user.FirstName), "UserLastName": strings.TrimSpace(user.LastName), "UserEmail": strings.TrimSpace(user.Email), @@ -411,11 +452,17 @@ func (ec *EngagementController) Redeem(c *gin.Context) { var set models.Settings _ = ec.DB.First(&set).Error ownerEmail := strings.TrimSpace(set.ContactEmail) - if ownerEmail == "" { ownerEmail = strings.TrimSpace(set.SMTPFrom) } + if ownerEmail == "" { + ownerEmail = strings.TrimSpace(set.SMTPFrom) + } if ownerEmail != "" { manageURL := "" if base := strings.TrimSpace(set.CanonicalBaseURL); base != "" { - if strings.HasSuffix(base, "/") { manageURL = base + "admin/engagement" } else { manageURL = base + "/admin/engagement" } + if strings.HasSuffix(base, "/") { + manageURL = base + "admin/engagement" + } else { + manageURL = base + "/admin/engagement" + } } fullName := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName)) _ = ec.Email.SendEmail(&email.EmailData{ @@ -492,480 +539,591 @@ func (ec *EngagementController) GetAchievements(c *gin.Context) { // Admin: list rewards // 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) } - if v == "false" || v == "0" { q = q.Where("active = ?", false) } - } - 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, gin.H{"items": items}) + 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) + } + if v == "false" || v == "0" { + q = q.Where("active = ?", false) + } + } + 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, gin.H{"items": items}) } // Admin: create reward // POST /api/v1/admin/engagement/rewards func (ec *EngagementController) AdminCreateReward(c *gin.Context) { - var body struct{ - Name string `json:"name"` - Type string `json:"type"` - CostPoints int64 `json:"cost_points"` - ImageURL string `json:"image_url"` - Stock int `json:"stock"` - Active *bool `json:"active"` - Metadata map[string]interface{} `json:"metadata"` - } - if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Type) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return - } - item := models.RewardItem{ Name: strings.TrimSpace(body.Name), Type: strings.TrimSpace(body.Type), CostPoints: body.CostPoints, ImageURL: strings.TrimSpace(body.ImageURL), Stock: body.Stock, Active: true } - if body.Active != nil { item.Active = *body.Active } - if body.Metadata != nil { item.Metadata = body.Metadata } - if err := ec.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create reward"}); return } - c.JSON(http.StatusOK, item) + var body struct { + Name string `json:"name"` + Type string `json:"type"` + CostPoints int64 `json:"cost_points"` + ImageURL string `json:"image_url"` + Stock int `json:"stock"` + Active *bool `json:"active"` + Metadata map[string]interface{} `json:"metadata"` + } + if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Type) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + // Disallow creating any avatar_* rewards via admin (managed automatically by system) + t := strings.ToLower(strings.TrimSpace(body.Type)) + if strings.HasPrefix(t, "avatar_") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar odměny nelze vytvářet v administraci"}) + return + } + item := models.RewardItem{Name: strings.TrimSpace(body.Name), Type: strings.TrimSpace(body.Type), CostPoints: body.CostPoints, ImageURL: strings.TrimSpace(body.ImageURL), Stock: body.Stock, Active: true} + if body.Active != nil { + item.Active = *body.Active + } + if body.Metadata != nil { + item.Metadata = body.Metadata + } + if err := ec.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create reward"}) + return + } + c.JSON(http.StatusOK, item) } // Admin: update reward // PUT /api/v1/admin/engagement/rewards/:id func (ec *EngagementController) AdminUpdateReward(c *gin.Context) { - id := c.Param("id") - var body struct{ - Name *string `json:"name"` - Type *string `json:"type"` - CostPoints *int64 `json:"cost_points"` - ImageURL *string `json:"image_url"` - Stock *int `json:"stock"` - Active *bool `json:"active"` - Metadata map[string]interface{} `json:"metadata"` - } - if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return } - // Load existing to enforce invariants on mandatory reward - var existing models.RewardItem - _ = ec.DB.First(&existing, id).Error - if strings.EqualFold(existing.Type, "avatar_upload_unlock") { - // 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) } - if body.Type != nil { updates["type"] = strings.TrimSpace(*body.Type) } - if body.CostPoints != nil { updates["cost_points"] = *body.CostPoints } - if body.ImageURL != nil { updates["image_url"] = strings.TrimSpace(*body.ImageURL) } - if body.Stock != nil { updates["stock"] = *body.Stock } - if body.Active != nil { updates["active"] = *body.Active } - if body.Metadata != nil { updates["metadata"] = body.Metadata } - if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return } - if err := ec.DB.Model(&models.RewardItem{}).Where("id = ?", id).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update reward"}); return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) + id := c.Param("id") + var body struct { + Name *string `json:"name"` + Type *string `json:"type"` + CostPoints *int64 `json:"cost_points"` + ImageURL *string `json:"image_url"` + Stock *int `json:"stock"` + Active *bool `json:"active"` + Metadata map[string]interface{} `json:"metadata"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + // Load existing to enforce invariants on mandatory reward + var existing models.RewardItem + _ = ec.DB.First(&existing, id).Error + if strings.EqualFold(existing.Type, "avatar_upload_unlock") { + // 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 + } + } + // Do not allow changing type to any avatar_* + if body.Type != nil { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(*body.Type)), "avatar_") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Nelze změnit typ na avatar_*"}) + return + } + } + // Do not allow deactivating any avatar_* reward (legacy ones should stay active) + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(existing.Type)), "avatar_") { + if body.Active != nil && *body.Active == false { + c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar odměny nelze deaktivovat"}) + return + } + } + updates := map[string]interface{}{} + if body.Name != nil { + updates["name"] = strings.TrimSpace(*body.Name) + } + if body.Type != nil { + updates["type"] = strings.TrimSpace(*body.Type) + } + if body.CostPoints != nil { + updates["cost_points"] = *body.CostPoints + } + if body.ImageURL != nil { + updates["image_url"] = strings.TrimSpace(*body.ImageURL) + } + if body.Stock != nil { + updates["stock"] = *body.Stock + } + if body.Active != nil { + updates["active"] = *body.Active + } + if body.Metadata != nil { + updates["metadata"] = body.Metadata + } + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"}) + return + } + if err := ec.DB.Model(&models.RewardItem{}).Where("id = ?", id).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update reward"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: delete reward // DELETE /api/v1/admin/engagement/rewards/:id func (ec *EngagementController) AdminDeleteReward(c *gin.Context) { - id := c.Param("id") - // Disallow deleting the mandatory reward - var existing models.RewardItem - if err := ec.DB.First(&existing, id).Error; err == nil { - if strings.EqualFold(existing.Type, "avatar_upload_unlock") { - c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deleted"}); return - } - } - if err := ec.DB.Delete(&models.RewardItem{}, "id = ?", id).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to delete reward"}); return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) + id := c.Param("id") + // Disallow deleting the mandatory reward + var existing models.RewardItem + if err := ec.DB.First(&existing, id).Error; err == nil { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(existing.Type)), "avatar_") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar odměny nelze mazat"}) + return + } + } + if err := ec.DB.Delete(&models.RewardItem{}, "id = ?", id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete reward"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: list redemptions // GET /api/v1/admin/engagement/redemptions func (ec *EngagementController) AdminListRedemptions(c *gin.Context) { - var items []models.RewardRedemption - q := ec.DB.Model(&models.RewardRedemption{}) - 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 to load redemptions"}); return - } - c.JSON(http.StatusOK, gin.H{"items": items}) + var items []models.RewardRedemption + q := ec.DB.Model(&models.RewardRedemption{}) + 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 to load redemptions"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // Admin: update redemption status (approve/reject/fulfill) // PATCH /api/v1/admin/engagement/redemptions/:id func (ec *EngagementController) AdminUpdateRedemptionStatus(c *gin.Context) { - id := c.Param("id") - var body struct{ Action string `json:"action"` } - if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return } - action := strings.ToLower(strings.TrimSpace(body.Action)) - var newStatus string - switch action { - case "approve": newStatus = "approved" - case "reject": newStatus = "rejected" - case "fulfill": newStatus = "fulfilled" - default: - c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return - } - // Load redemption to know user and reward - var red models.RewardRedemption - if err := ec.DB.First(&red, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error":"Redemption not found"}); return - } - // If rejecting a pending manual redemption, refund points and restore stock in a transaction - if newStatus == "rejected" { - // Load reward to know cost/stock - var reward models.RewardItem - if err := ec.DB.First(&reward, red.RewardID).Error; err == nil { - tx := ec.DB.Begin() - // Update status first - if err := tx.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return } - // Refund points - if err := tx.Model(&models.UserProfile{}).Where("user_id = ?", red.UserID).UpdateColumn("points", gorm.Expr("points + ?", reward.CostPoints)).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to refund points"}); return } - // Log refund transaction (no XP) - _ = tx.Create(&models.PointsTransaction{ UserID: red.UserID, Delta: reward.CostPoints, XPDelta: 0, Reason: "redeem_refund", Meta: datatypes.JSONMap{"reward_id": reward.ID, "reward_type": reward.Type} }).Error - // Restore stock when finite - if reward.Stock >= 0 { - _ = tx.Model(&models.RewardItem{}).Where("id = ?", reward.ID).UpdateColumn("stock", gorm.Expr("stock + 1")).Error - } - if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to finalize refund"}); return } - } else { - // Fallback: update status only if reward missing - if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return } - } - } else { - if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return - } - } - // Notify user about final decision for manual rewards (best-effort) - if (newStatus == "fulfilled" || newStatus == "rejected") && ec.Email != nil { - var user models.User - _ = ec.DB.First(&user, red.UserID).Error - var reward models.RewardItem - _ = ec.DB.First(&reward, red.RewardID).Error - if strings.TrimSpace(user.Email) != "" { - _ = ec.Email.SendEmail(&email.EmailData{ - Subject: "Aktualizace stavu uplatněné odměny", - To: []string{strings.TrimSpace(user.Email)}, - Template: "reward_redeemed_user", - Data: map[string]interface{}{ - "RewardName": reward.Name, - "RewardType": reward.Type, - "Points": reward.CostPoints, - "Status": newStatus, - "RedeemedAt": time.Now().Format(time.RFC3339), - "UserFirstName": strings.TrimSpace(user.FirstName), - "UserLastName": strings.TrimSpace(user.LastName), - "UserEmail": strings.TrimSpace(user.Email), - }, - }) - } - } - c.JSON(http.StatusOK, gin.H{"ok": true, "status": newStatus}) + id := c.Param("id") + var body struct { + Action string `json:"action"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + action := strings.ToLower(strings.TrimSpace(body.Action)) + var newStatus string + switch action { + case "approve": + newStatus = "approved" + case "reject": + newStatus = "rejected" + case "fulfill": + newStatus = "fulfilled" + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"}) + return + } + // Load redemption to know user and reward + var red models.RewardRedemption + if err := ec.DB.First(&red, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Redemption not found"}) + return + } + // If rejecting a pending manual redemption, refund points and restore stock in a transaction + if newStatus == "rejected" { + // Load reward to know cost/stock + var reward models.RewardItem + if err := ec.DB.First(&reward, red.RewardID).Error; err == nil { + tx := ec.DB.Begin() + // Update status first + if err := tx.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"}) + return + } + // Refund points + if err := tx.Model(&models.UserProfile{}).Where("user_id = ?", red.UserID).UpdateColumn("points", gorm.Expr("points + ?", reward.CostPoints)).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refund points"}) + return + } + // Log refund transaction (no XP) + _ = tx.Create(&models.PointsTransaction{UserID: red.UserID, Delta: reward.CostPoints, XPDelta: 0, Reason: "redeem_refund", Meta: datatypes.JSONMap{"reward_id": reward.ID, "reward_type": reward.Type}}).Error + // Restore stock when finite + if reward.Stock >= 0 { + _ = tx.Model(&models.RewardItem{}).Where("id = ?", reward.ID).UpdateColumn("stock", gorm.Expr("stock + 1")).Error + } + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize refund"}) + return + } + } else { + // Fallback: update status only if reward missing + if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"}) + return + } + } + } else { + if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"}) + return + } + } + // Notify user about final decision for manual rewards (best-effort) + if (newStatus == "fulfilled" || newStatus == "rejected") && ec.Email != nil { + var user models.User + _ = ec.DB.First(&user, red.UserID).Error + var reward models.RewardItem + _ = ec.DB.First(&reward, red.RewardID).Error + if strings.TrimSpace(user.Email) != "" { + _ = ec.Email.SendEmail(&email.EmailData{ + Subject: "Aktualizace stavu uplatněné odměny", + To: []string{strings.TrimSpace(user.Email)}, + Template: "reward_redeemed_user", + Data: map[string]interface{}{ + "RewardName": reward.Name, + "RewardType": reward.Type, + "Points": reward.CostPoints, + "Status": newStatus, + "RedeemedAt": time.Now().Format(time.RFC3339), + "UserFirstName": strings.TrimSpace(user.FirstName), + "UserLastName": strings.TrimSpace(user.LastName), + "UserEmail": strings.TrimSpace(user.Email), + }, + }) + } + } + c.JSON(http.StatusOK, gin.H{"ok": true, "status": newStatus}) } // GET /api/v1/engagement/transactions (auth) // Query: limit (default 50, max 200), reason? func (ec *EngagementController) GetMyTransactions(c *gin.Context) { - uid, _ := c.Get("userID") - userID := uid.(uint) - // Admins: engagement hidden – return empty list - if c.GetString("userRole") == "admin" { - c.JSON(http.StatusOK, gin.H{"items": []models.PointsTransaction{}}) - return - } - limit := 50 - if v := strings.TrimSpace(c.Query("limit")); v != "" { - if n, err := strconv.Atoi(v); err == nil { - if n > 0 && n <= 200 { limit = n } - } - } - q := ec.DB.Model(&models.PointsTransaction{}).Where("user_id = ?", userID) - if r := strings.TrimSpace(c.Query("reason")); r != "" { - q = q.Where("reason = ?", r) - } - var items []models.PointsTransaction - if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load transactions"}); return - } - c.JSON(http.StatusOK, gin.H{"items": items}) + uid, _ := c.Get("userID") + userID := uid.(uint) + // Admins: engagement hidden – return empty list + if c.GetString("userRole") == "admin" { + c.JSON(http.StatusOK, gin.H{"items": []models.PointsTransaction{}}) + return + } + limit := 50 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + if n > 0 && n <= 200 { + limit = n + } + } + } + q := ec.DB.Model(&models.PointsTransaction{}).Where("user_id = ?", userID) + if r := strings.TrimSpace(c.Query("reason")); r != "" { + q = q.Where("reason = ?", r) + } + var items []models.PointsTransaction + if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load transactions"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // POST /api/v1/engagement/checkin (auth) // Awards daily check-in points (cap 1/day via service caps); Admins do not earn points func (ec *EngagementController) Checkin(c *gin.Context) { - uid, _ := c.Get("userID") - userID := uid.(uint) - // Admins: engagement disabled (no-op) - if c.GetString("userRole") == "admin" { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) - return - } - // Fast check if already checked in today - now := time.Now() - startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - var cnt int64 - _ = ec.DB.Model(&models.PointsTransaction{}). - Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "daily_checkin", startOfDay). - Count(&cnt).Error - already := cnt > 0 - svc := services.NewEngagementService(ec.DB) - if !already { - _, _ = svc.AwardPointsCapped(userID, 8, "daily_checkin", map[string]interface{}{"at": now.Format(time.RFC3339)}) - } - // Ensure profile for response - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": !already, "points": up.Points, "level": up.Level, "xp": up.XP}) + uid, _ := c.Get("userID") + userID := uid.(uint) + // Admins: engagement disabled (no-op) + if c.GetString("userRole") == "admin" { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) + return + } + // Fast check if already checked in today + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + var cnt int64 + _ = ec.DB.Model(&models.PointsTransaction{}). + Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "daily_checkin", startOfDay). + Count(&cnt).Error + already := cnt > 0 + svc := services.NewEngagementService(ec.DB) + if !already { + _, _ = svc.AwardPointsCapped(userID, 8, "daily_checkin", map[string]interface{}{"at": now.Format(time.RFC3339)}) + } + // Ensure profile for response + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": !already, "points": up.Points, "level": up.Level, "xp": up.XP}) } // POST /api/v1/engagement/article-read (auth) // Awards small points for unique article reads (cap 3/day + dedupe per article); Admins do not earn points func (ec *EngagementController) ArticleRead(c *gin.Context) { - uid, _ := c.Get("userID") - userID := uid.(uint) - // Admins: engagement disabled (no-op) - if c.GetString("userRole") == "admin" { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) - return - } - var body struct{ ArticleID uint `json:"article_id"` } - if err := c.ShouldBindJSON(&body); err != nil || body.ArticleID == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) - return - } - // Dedupe per article: check recent transactions with meta.article_id == body.ArticleID - var txs []models.PointsTransaction - _ = ec.DB.Where("user_id = ? AND reason = ?", userID, "article_read").Order("created_at DESC").Limit(200).Find(&txs).Error - for _, t := range txs { - if t.Meta != nil { - if v, ok := t.Meta["article_id"]; ok { - switch vv := v.(type) { - case string: - if strings.TrimSpace(vv) == strconv.FormatUint(uint64(body.ArticleID), 10) { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) - return - } - case float64: - if uint(vv) == body.ArticleID { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) - return - } - } - } - } - } - svc := services.NewEngagementService(ec.DB) - _, _ = svc.AwardPointsCapped(userID, 2, "article_read", map[string]interface{}{"article_id": strconv.FormatUint(uint64(body.ArticleID), 10)}) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": true, "points": up.Points, "level": up.Level, "xp": up.XP}) + uid, _ := c.Get("userID") + userID := uid.(uint) + // Admins: engagement disabled (no-op) + if c.GetString("userRole") == "admin" { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) + return + } + var body struct { + ArticleID uint `json:"article_id"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.ArticleID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + // Dedupe per article: check recent transactions with meta.article_id == body.ArticleID + var txs []models.PointsTransaction + _ = ec.DB.Where("user_id = ? AND reason = ?", userID, "article_read").Order("created_at DESC").Limit(200).Find(&txs).Error + for _, t := range txs { + if t.Meta != nil { + if v, ok := t.Meta["article_id"]; ok { + switch vv := v.(type) { + case string: + if strings.TrimSpace(vv) == strconv.FormatUint(uint64(body.ArticleID), 10) { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) + return + } + case float64: + if uint(vv) == body.ArticleID { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) + return + } + } + } + } + } + svc := services.NewEngagementService(ec.DB) + _, _ = svc.AwardPointsCapped(userID, 2, "article_read", map[string]interface{}{"article_id": strconv.FormatUint(uint64(body.ArticleID), 10)}) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": true, "points": up.Points, "level": up.Level, "xp": up.XP}) } // GET /api/v1/engagement/leaderboard (auth) // Query: metric=points|level|xp, limit (default 20, max 100) func (ec *EngagementController) GetLeaderboard(c *gin.Context) { - metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) - if metric == "" { - metric = "points" - } - limit := 20 - if v := strings.TrimSpace(c.Query("limit")); v != "" { - if n, err := strconv.Atoi(v); err == nil { - if n > 0 && n <= 100 { - limit = n - } - } - } + metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) + if metric == "" { + metric = "points" + } + limit := 20 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + if n > 0 && n <= 100 { + limit = n + } + } + } - type row struct { - UserID uint - FirstName string - LastName string - Username string - Role string - Points int64 - Level int - XP int64 - AvatarURL string - AnimatedAvatarURL string - } - q := ec.DB.Table("user_profiles AS up"). - Select("up.user_id, u.first_name, u.last_name, up.username, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). - Joins("JOIN users u ON u.id = up.user_id") - switch metric { - case "xp": - q = q.Order("up.xp DESC, up.points DESC, up.level DESC") - case "level": - q = q.Order("up.level DESC, up.xp DESC, up.points DESC") - default: - q = q.Order("up.points DESC, up.level DESC, up.xp DESC") - } - q = q.Limit(limit) + type row struct { + UserID uint + FirstName string + LastName string + Username string + Role string + Points int64 + Level int + XP int64 + AvatarURL string + AnimatedAvatarURL string + } + q := ec.DB.Table("user_profiles AS up"). + Select("up.user_id, u.first_name, u.last_name, up.username, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). + Joins("JOIN users u ON u.id = up.user_id") + switch metric { + case "xp": + q = q.Order("up.xp DESC, up.points DESC, up.level DESC") + case "level": + q = q.Order("up.level DESC, up.xp DESC, up.points DESC") + default: + q = q.Order("up.points DESC, up.level DESC, up.xp DESC") + } + q = q.Limit(limit) - var rows []row - if err := q.Scan(&rows).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) - return - } + var rows []row + if err := q.Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) + return + } - items := make([]gin.H, 0, len(rows)) - for i, r := range rows { - items = append(items, gin.H{ - "rank": i + 1, - "user_id": r.UserID, - "first_name": r.FirstName, - "last_name": r.LastName, - "username": r.Username, - "role": r.Role, - "points": r.Points, - "level": r.Level, - "xp": r.XP, - "avatar_url": r.AvatarURL, - "animated_avatar_url": r.AnimatedAvatarURL, - }) - } - c.JSON(http.StatusOK, gin.H{"items": items}) + items := make([]gin.H, 0, len(rows)) + for i, r := range rows { + items = append(items, gin.H{ + "rank": i + 1, + "user_id": r.UserID, + "first_name": r.FirstName, + "last_name": r.LastName, + "username": r.Username, + "role": r.Role, + "points": r.Points, + "level": r.Level, + "xp": r.XP, + "avatar_url": r.AvatarURL, + "animated_avatar_url": r.AnimatedAvatarURL, + }) + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // GET /api/v1/admin/engagement/leaderboard (admin) // Query: metric=points|level|xp, limit (default 50, max 1000) func (ec *EngagementController) AdminGetLeaderboard(c *gin.Context) { - metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) - if metric == "" { - metric = "points" - } - limit := 50 - if v := strings.TrimSpace(c.Query("limit")); v != "" { - if n, err := strconv.Atoi(v); err == nil { - if n > 0 && n <= 1000 { - limit = n - } - } - } + metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) + if metric == "" { + metric = "points" + } + limit := 50 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + if n > 0 && n <= 1000 { + limit = n + } + } + } - type row struct { - UserID uint - FirstName string - LastName string - Email string - Role string - Points int64 - Level int - XP int64 - AvatarURL string - AnimatedAvatarURL string - } - q := ec.DB.Table("user_profiles AS up"). - Select("up.user_id, u.first_name, u.last_name, u.email, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). - Joins("JOIN users u ON u.id = up.user_id") - switch metric { - case "xp": - q = q.Order("up.xp DESC, up.points DESC, up.level DESC") - case "level": - q = q.Order("up.level DESC, up.xp DESC, up.points DESC") - default: - q = q.Order("up.points DESC, up.level DESC, up.xp DESC") - } - q = q.Limit(limit) + type row struct { + UserID uint + FirstName string + LastName string + Email string + Role string + Points int64 + Level int + XP int64 + AvatarURL string + AnimatedAvatarURL string + } + q := ec.DB.Table("user_profiles AS up"). + Select("up.user_id, u.first_name, u.last_name, u.email, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). + Joins("JOIN users u ON u.id = up.user_id") + switch metric { + case "xp": + q = q.Order("up.xp DESC, up.points DESC, up.level DESC") + case "level": + q = q.Order("up.level DESC, up.xp DESC, up.points DESC") + default: + q = q.Order("up.points DESC, up.level DESC, up.xp DESC") + } + q = q.Limit(limit) - var rows []row - if err := q.Scan(&rows).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) - return - } - items := make([]gin.H, 0, len(rows)) - for i, r := range rows { - items = append(items, gin.H{ - "rank": i + 1, - "user_id": r.UserID, - "first_name": r.FirstName, - "last_name": r.LastName, - "email": r.Email, - "role": r.Role, - "points": r.Points, - "level": r.Level, - "xp": r.XP, - "avatar_url": r.AvatarURL, - "animated_avatar_url": r.AnimatedAvatarURL, - }) - } - c.JSON(http.StatusOK, gin.H{"items": items}) + var rows []row + if err := q.Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) + return + } + items := make([]gin.H, 0, len(rows)) + for i, r := range rows { + items = append(items, gin.H{ + "rank": i + 1, + "user_id": r.UserID, + "first_name": r.FirstName, + "last_name": r.LastName, + "email": r.Email, + "role": r.Role, + "points": r.Points, + "level": r.Level, + "xp": r.XP, + "avatar_url": r.AvatarURL, + "animated_avatar_url": r.AnimatedAvatarURL, + }) + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // GET /api/v1/admin/engagement/profile/:user_id (admin) func (ec *EngagementController) AdminGetUserProfile(c *gin.Context) { - userIDStr := strings.TrimSpace(c.Param("user_id")) - if userIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error":"user_id required"}); return } - var up models.UserProfile - if err := ec.DB.Where("user_id = ?", userIDStr).First(&up).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error":"Profile not found"}); return - } - // Optionally include user basic info - var u models.User - _ = ec.DB.Select("id, first_name, last_name, email, role").Where("id = ?", userIDStr).First(&u).Error - c.JSON(http.StatusOK, gin.H{ - "user_id": up.UserID, - "first_name": strings.TrimSpace(u.FirstName), - "last_name": strings.TrimSpace(u.LastName), - "email": strings.TrimSpace(u.Email), - "role": u.Role, - "points": up.Points, - "level": up.Level, - "xp": up.XP, - "username": up.Username, - "avatar_url": up.AvatarURL, - "animated_avatar_url": up.AnimatedAvatarURL, - "avatar_upload_unlocked": up.AvatarUploadUnlocked, - "animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked, - }) + userIDStr := strings.TrimSpace(c.Param("user_id")) + if userIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id required"}) + return + } + var up models.UserProfile + if err := ec.DB.Where("user_id = ?", userIDStr).First(&up).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"}) + return + } + // Optionally include user basic info + var u models.User + _ = ec.DB.Select("id, first_name, last_name, email, role").Where("id = ?", userIDStr).First(&u).Error + c.JSON(http.StatusOK, gin.H{ + "user_id": up.UserID, + "first_name": strings.TrimSpace(u.FirstName), + "last_name": strings.TrimSpace(u.LastName), + "email": strings.TrimSpace(u.Email), + "role": u.Role, + "points": up.Points, + "level": up.Level, + "xp": up.XP, + "username": up.Username, + "avatar_url": up.AvatarURL, + "animated_avatar_url": up.AnimatedAvatarURL, + "avatar_upload_unlocked": up.AvatarUploadUnlocked, + "animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked, + }) } // Admin: list points transactions with optional filters // GET /api/v1/admin/engagement/transactions?user_id=&reason=&limit= func (ec *EngagementController) AdminListTransactions(c *gin.Context) { - q := ec.DB.Model(&models.PointsTransaction{}) - if uid := strings.TrimSpace(c.Query("user_id")); uid != "" { q = q.Where("user_id = ?", uid) } - if r := strings.TrimSpace(c.Query("reason")); r != "" { q = q.Where("reason = ?", r) } - limit := 100 - if v := strings.TrimSpace(c.Query("limit")); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 { limit = n } - } - var items []models.PointsTransaction - if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load transactions"}); return - } - c.JSON(http.StatusOK, gin.H{"items": items}) + q := ec.DB.Model(&models.PointsTransaction{}) + if uid := strings.TrimSpace(c.Query("user_id")); uid != "" { + q = q.Where("user_id = ?", uid) + } + if r := strings.TrimSpace(c.Query("reason")); r != "" { + q = q.Where("reason = ?", r) + } + limit := 100 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 { + limit = n + } + } + var items []models.PointsTransaction + if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load transactions"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) } diff --git a/internal/controllers/gallery_controller.go b/internal/controllers/gallery_controller.go index 0582189..4958d6e 100644 --- a/internal/controllers/gallery_controller.go +++ b/internal/controllers/gallery_controller.go @@ -5,11 +5,13 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" "time" + "fotbal-club/internal/config" "fotbal-club/internal/services" "fotbal-club/pkg/logger" @@ -149,9 +151,10 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) { body.PhotoLimit = 50 // Default to 50 photos per album } - // Call external API - apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d", - body.Link, body.PhotoLimit) + // Call external API (configurable base) + apiBase := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/") + apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d", + apiBase, url.QueryEscape(body.Link), body.PhotoLimit) logger.Info("Fetching album from Zonerama API: %s", apiURL) @@ -242,13 +245,13 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) { } logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos)) - + // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } - + c.JSON(http.StatusOK, gin.H{ "message": "Album fetched and saved successfully", "album": albumData, @@ -300,13 +303,13 @@ func (gc *GalleryController) DeleteAlbum(c *gin.Context) { } logger.Info("Deleted album: %s", albumID) - + // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } - + c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"}) } @@ -316,27 +319,27 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) { var settings struct { GalleryURL string `json:"gallery_url"` } - + if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil { logger.Error("Failed to load settings: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"}) return } - + zoneramaURL := strings.TrimSpace(settings.GalleryURL) if zoneramaURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"}) return } - + // Validate it's a Zonerama URL if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") { c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"}) return } - + logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL) - + // Call the refresh service in a goroutine to avoid blocking go func() { if err := services.RefreshZoneramaNow(zoneramaURL); err != nil { @@ -349,7 +352,7 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) { } } }() - + c.JSON(http.StatusOK, gin.H{ "message": "Zonerama refresh started", "url": zoneramaURL, diff --git a/internal/controllers/navigation_controller.go b/internal/controllers/navigation_controller.go index a5786a5..3e67fd2 100644 --- a/internal/controllers/navigation_controller.go +++ b/internal/controllers/navigation_controller.go @@ -262,6 +262,11 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) { updates["requires_admin"] = b } } + if v, ok := raw["allow_editor"]; ok { + if b, ok2 := v.(bool); ok2 { + updates["allow_editor"] = b + } + } if len(updates) == 0 { // Nothing to update @@ -372,6 +377,72 @@ func (nc *NavigationController) GetSocialLinks(c *gin.Context) { c.JSON(http.StatusOK, links) } +// GetEditorAllowedAdminNav returns admin navigation items that are explicitly allowed for editors +// Top-level items are included only when: +// - type != dropdown and allow_editor = true (and visible = true), or +// - type == dropdown and it has at least one child with allow_editor = true (and visible = true) +// +// Children are filtered to allow_editor = true and visible = true +func (nc *NavigationController) GetEditorAllowedAdminNav(c *gin.Context) { + var top []models.NavigationItem + // Load all top-level admin items (categories and direct items) + if err := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND visible = ?", true, true). + Order("display_order ASC"). + Preload("Children", func(db *gorm.DB) *gorm.DB { + return db.Where("requires_admin = ? AND visible = ? AND allow_editor = ?", true, true, true).Order("display_order ASC") + }). + Find(&top).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch editor navigation"}) + return + } + + // Filter according to allow_editor rules + out := make([]models.NavigationItem, 0, len(top)) + // Only allow a curated set of admin pages that have editor-capable APIs + allowed := map[string]bool{ + "articles": true, + "activities": true, + "shortlinks": true, + } + for i := range top { + it := top[i] + include := false + if it.Type == models.NavTypeDropdown { + // Filter children by page_type allow-list (children already have allow_editor=true from preload) + if len(it.Children) > 0 { + children := make([]models.NavigationItem, 0, len(it.Children)) + for _, ch := range it.Children { + if allowed[ch.PageType] { + // ensure URL is set + if ch.URL == "" { + ch.URL = ch.GetURL() + } + children = append(children, ch) + } + } + it.Children = children + if len(it.Children) > 0 { + include = true + } + } + } else { + // direct admin page: include only when marked allow_editor + if it.AllowEditor && allowed[it.PageType] { + include = true + } + } + if include { + // Ensure URLs are computed + if it.URL == "" { + it.URL = it.GetURL() + } + out = append(out, it) + } + } + + c.JSON(http.StatusOK, out) +} + // GetAllSocialLinks returns all social links including hidden ones (admin only) // @Summary Get all social links (admin) // @Description Returns all social links for admin management @@ -593,7 +664,12 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) { 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} + allowEditor := false + switch pageType { + case "articles", "activities", "shortlinks": + allowEditor = true + } + child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true, AllowEditor: allowEditor} child.ParentID = &pid return tx.Create(child).Error } diff --git a/internal/controllers/shortlink_controller.go b/internal/controllers/shortlink_controller.go index e34256f..014ec22 100644 --- a/internal/controllers/shortlink_controller.go +++ b/internal/controllers/shortlink_controller.go @@ -29,66 +29,68 @@ type ShortLinkController struct { // Restrictions: only allows shortening links pointing to this site (request host) // or to the configured FrontendBaseURL. Intended for visitor share/copy flows. func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) { - var body struct { - TargetURL string `json:"target_url"` - Title string `json:"title"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - target, err := parseTarget(body.TargetURL) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) - return - } - tu, _ := url.Parse(target) - if tu == nil || tu.Host == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) - return - } - // Allow only same-site or configured frontend host - reqHost := c.Request.Host - stripPort := func(h string) string { - if i := strings.IndexByte(h, ':'); i >= 0 { return h[:i] } - return h - } - allowed := stripPort(tu.Host) == stripPort(reqHost) - if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" { - if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" { - if stripPort(fu.Host) == stripPort(tu.Host) { - allowed = true - } - } - } - if !allowed { - c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"}) - return - } + var body struct { + TargetURL string `json:"target_url"` + Title string `json:"title"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + target, err := parseTarget(body.TargetURL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) + return + } + tu, _ := url.Parse(target) + if tu == nil || tu.Host == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) + return + } + // Allow only same-site or configured frontend host + reqHost := c.Request.Host + stripPort := func(h string) string { + if i := strings.IndexByte(h, ':'); i >= 0 { + return h[:i] + } + return h + } + allowed := stripPort(tu.Host) == stripPort(reqHost) + if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" { + if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" { + if stripPort(fu.Host) == stripPort(tu.Host) { + allowed = true + } + } + } + if !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"}) + return + } - // Deterministic code from URL so repeated calls return same shortlink - code := "p-" + codeFromHash(target, 7) - link := models.ShortLink{ - Code: code, - TargetURL: target, - Title: strings.TrimSpace(body.Title), - Active: true, - } - if err := s.DB.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "code"}}, - DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}), - }).Create(&link).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()}) - return - } - var saved models.ShortLink - if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil { - saved = link - } - scheme := getScheme(c) - host := c.Request.Host - shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code) - c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved}) + // Deterministic code from URL so repeated calls return same shortlink + code := "p-" + codeFromHash(target, 7) + link := models.ShortLink{ + Code: code, + TargetURL: target, + Title: strings.TrimSpace(body.Title), + Active: true, + } + if err := s.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "code"}}, + DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}), + }).Create(&link).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()}) + return + } + var saved models.ShortLink + if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil { + saved = link + } + scheme := getScheme(c) + host := c.Request.Host + shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code) + c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved}) } func NewShortLinkController(db *gorm.DB) *ShortLinkController { @@ -125,7 +127,9 @@ func hashIPShort(ip string) string { } func codeFromHash(s string, n int) string { - if n <= 0 { n = 7 } + if n <= 0 { + n = 7 + } alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" sum := sha256.Sum256([]byte(s)) out := make([]byte, n) @@ -137,20 +141,24 @@ func codeFromHash(s string, n int) string { } func sanitizeCode(in string) string { - s := strings.TrimSpace(in) - if s == "" { return "" } - // filter allowed runes - rb := make([]rune, 0, len(s)) - for _, ch := range s { - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' { - rb = append(rb, ch) - } - } - if len(rb) == 0 { return "" } - if len(rb) > 16 { - rb = rb[:16] - } - return string(rb) + s := strings.TrimSpace(in) + if s == "" { + return "" + } + // filter allowed runes + rb := make([]rune, 0, len(s)) + for _, ch := range s { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' { + rb = append(rb, ch) + } + } + if len(rb) == 0 { + return "" + } + if len(rb) > 16 { + rb = rb[:16] + } + return string(rb) } func getScheme(c *gin.Context) string { @@ -174,11 +182,20 @@ func parseTarget(raw string) (string, error) { raw = string(dec) } } - u, err := url.Parse(raw) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return "", errors.New("invalid url") + // Try as-is first + if u, err := url.Parse(raw); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" { + return u.String(), nil } - return u.String(), nil + // If scheme is missing, try https:// fallback, then http:// + if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { + if u, err := url.Parse("https://" + raw); err == nil && u.Host != "" { + return u.String(), nil + } + if u, err := url.Parse("http://" + raw); err == nil && u.Host != "" { + return u.String(), nil + } + } + return "", errors.New("invalid url") } func (s *ShortLinkController) RedirectShort(c *gin.Context) { @@ -274,23 +291,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) { return } code := sanitizeCode(strings.TrimSpace(body.Code)) - if code == "" { - for i := 0; i < 5; i++ { - cnd, _ := randCode(7) - var cnt int64 - s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt) - if cnt == 0 { - code = cnd - break - } - } - } + if code == "" { + for i := 0; i < 5; i++ { + cnd, _ := randCode(7) + var cnt int64 + s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt) + if cnt == 0 { + code = cnd + break + } + } + } if code == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"}) return } active := true - if body.Active != nil { active = *body.Active } + if body.Active != nil { + active = *body.Active + } link := models.ShortLink{ Code: code, TargetURL: target, @@ -329,22 +348,37 @@ func (s *ShortLinkController) ListShortLinks(c *gin.Context) { func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) { id := strings.TrimSpace(c.Param("id")) - if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return } + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}) + return + } var link models.ShortLink - if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return } - start := time.Now().AddDate(0,0,-30) - type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` } + if err := s.DB.First(&link, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + start := time.Now().AddDate(0, 0, -30) + type Row struct { + Date string `json:"date"` + Count int64 `json:"count"` + } var rows []Row s.DB.Model(&models.LinkClick{}). Select("DATE(created_at) as date, COUNT(*) as count"). Where("short_link_id = ? AND created_at >= ?", link.ID, start). Group("DATE(created_at)").Order("date ASC").Scan(&rows) - var refRows []struct{ Referrer string; Count int64 } + var refRows []struct { + Referrer string + Count int64 + } s.DB.Model(&models.LinkClick{}). Select("referrer, COUNT(*) as count"). Where("short_link_id = ? AND created_at >= ?", link.ID, start). Group("referrer").Order("count DESC").Limit(20).Scan(&refRows) - var utmRows []struct{ Source, Medium, Campaign string; Count int64 } + var utmRows []struct { + Source, Medium, Campaign string + Count int64 + } s.DB.Model(&models.LinkClick{}). Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count"). Where("short_link_id = ? AND created_at >= ?", link.ID, start). diff --git a/internal/controllers/sweepstakes_controller.go b/internal/controllers/sweepstakes_controller.go index 3d9976a..5f25a06 100644 --- a/internal/controllers/sweepstakes_controller.go +++ b/internal/controllers/sweepstakes_controller.go @@ -29,12 +29,19 @@ func NewSweepstakesController(db *gorm.DB, es email.EmailService) *SweepstakesCo 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 } + 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 + c.JSON(http.StatusNotFound, gin.H{"error": "Not available"}) + return + } + var winners []struct { + UserID uint `json:"user_id"` + PrizeName string `json:"prize_name"` } - 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"` @@ -48,384 +55,589 @@ func (sc *SweepstakesController) PublicVisualData(c *gin.Context) { 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 }) + c.JSON(http.StatusOK, gin.H{"sweepstake": s, "entries": entries, "winners": winners}) } // Admin: set or change prize for a specific winner // PATCH /api/v1/admin/sweepstakes/:id/winners/:winner_id/prize { "prize_id": 123 } func (sc *SweepstakesController) AdminSetWinnerPrize(c *gin.Context) { - wid := strings.TrimSpace(c.Param("winner_id")) - var body struct{ PrizeID uint `json:"prize_id"` } - if err := c.ShouldBindJSON(&body); err != nil || body.PrizeID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize"}); return } - // Load prize name - var p models.SweepstakePrize - if err := sc.DB.First(&p, body.PrizeID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Prize not found"}); return } - updates := map[string]interface{}{ "prize_id": p.ID, "prize_name": strings.TrimSpace(p.Name) } - if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - c.JSON(http.StatusOK, gin.H{"ok": true}) + wid := strings.TrimSpace(c.Param("winner_id")) + var body struct { + PrizeID uint `json:"prize_id"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.PrizeID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid prize"}) + return + } + // Load prize name + var p models.SweepstakePrize + if err := sc.DB.First(&p, body.PrizeID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Prize not found"}) + return + } + updates := map[string]interface{}{"prize_id": p.ID, "prize_name": strings.TrimSpace(p.Name)} + if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: update winner status (claim/delivered/pending) // PATCH /api/v1/admin/sweepstakes/:id/winners/:winner_id { "claim_status": "claimed|delivered|pending", "claim_note":"..." } func (sc *SweepstakesController) AdminUpdateWinner(c *gin.Context) { - wid := strings.TrimSpace(c.Param("winner_id")) - var body struct{ - ClaimStatus string `json:"claim_status"` - ClaimNote string `json:"claim_note"` - } - if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return } - st := strings.ToLower(strings.TrimSpace(body.ClaimStatus)) - if st == "" { st = "pending" } - switch st { - case "pending","claimed","delivered": - default: - c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid status"}); return - } - // Load winner to evaluate prize awarding - var w models.SweepstakeWinner - if err := sc.DB.First(&w, wid).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return } - // Update fields - updates := map[string]interface{}{ "claim_status": st } - if strings.TrimSpace(body.ClaimNote) != "" { updates["claim_note"] = strings.TrimSpace(body.ClaimNote) } - // Award non-physical prizes only once when moving to claimed/delivered - shouldAward := (st == "claimed" || st == "delivered") && (w.AwardedAt == nil) - if shouldAward && w.PrizeID != nil { - var p models.SweepstakePrize - if err := sc.DB.First(&p, *w.PrizeID).Error; err == nil { - if p.Kind == "points" || p.Kind == "xp" || p.Kind == "points_xp" { - svc := services.NewEngagementService(sc.DB) - var pointsDelta, xpDelta int64 - switch p.Kind { - case "points": pointsDelta, xpDelta = p.Points, 0 - case "xp": pointsDelta, xpDelta = 0, p.XP - case "points_xp": pointsDelta, xpDelta = p.Points, p.XP - } - if pointsDelta != 0 || xpDelta != 0 { - _, _ = svc.AwardPointsAndXP(w.UserID, pointsDelta, xpDelta, "sweepstake_prize", map[string]interface{}{"prize_id": p.ID, "sweepstake_id": w.SweepstakeID}) - } - now := time.Now() - updates["awarded_at"] = &now - } - } - } - if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - c.JSON(http.StatusOK, gin.H{"ok": true}) + wid := strings.TrimSpace(c.Param("winner_id")) + var body struct { + ClaimStatus string `json:"claim_status"` + ClaimNote string `json:"claim_note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + st := strings.ToLower(strings.TrimSpace(body.ClaimStatus)) + if st == "" { + st = "pending" + } + switch st { + case "pending", "claimed", "delivered": + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status"}) + return + } + // Load winner to evaluate prize awarding + var w models.SweepstakeWinner + if err := sc.DB.First(&w, wid).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + // Update fields + updates := map[string]interface{}{"claim_status": st} + if strings.TrimSpace(body.ClaimNote) != "" { + updates["claim_note"] = strings.TrimSpace(body.ClaimNote) + } + // Award non-physical prizes only once when moving to claimed/delivered + shouldAward := (st == "claimed" || st == "delivered") && (w.AwardedAt == nil) + if shouldAward && w.PrizeID != nil { + var p models.SweepstakePrize + if err := sc.DB.First(&p, *w.PrizeID).Error; err == nil { + if p.Kind == "points" || p.Kind == "xp" || p.Kind == "points_xp" { + svc := services.NewEngagementService(sc.DB) + var pointsDelta, xpDelta int64 + switch p.Kind { + case "points": + pointsDelta, xpDelta = p.Points, 0 + case "xp": + pointsDelta, xpDelta = 0, p.XP + case "points_xp": + pointsDelta, xpDelta = p.Points, p.XP + } + if pointsDelta != 0 || xpDelta != 0 { + _, _ = svc.AwardPointsAndXP(w.UserID, pointsDelta, xpDelta, "sweepstake_prize", map[string]interface{}{"prize_id": p.ID, "sweepstake_id": w.SweepstakeID}) + } + now := time.Now() + updates["awarded_at"] = &now + } + } + } + if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: list prizes // GET /api/v1/admin/sweepstakes/:id/prizes func (sc *SweepstakesController) AdminListPrizes(c *gin.Context) { - id := c.Param("id") - var items []models.SweepstakePrize - if err := sc.DB.Where("sweepstake_id = ?", id).Order("display_order ASC, id ASC").Find(&items).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return - } - c.JSON(http.StatusOK, gin.H{"items": items}) + id := c.Param("id") + var items []models.SweepstakePrize + if err := sc.DB.Where("sweepstake_id = ?", id).Order("display_order ASC, id ASC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // Admin: create prize // POST /api/v1/admin/sweepstakes/:id/prizes func (sc *SweepstakesController) AdminCreatePrize(c *gin.Context) { - sid := strings.TrimSpace(c.Param("id")) - var s models.Sweepstake - if err := sc.DB.First(&s, sid).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Sweepstake not found"}); return } - var body struct{ - Name string `json:"name"` - Description string `json:"description"` - ImageURL string `json:"image_url"` - Value string `json:"value"` - Quantity int `json:"quantity"` - DisplayOrder int `json:"display_order"` - Kind string `json:"kind"` - Points int64 `json:"points"` - XP int64 `json:"xp"` - } - if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return - } - // Normalize prize kind/values - kind := strings.ToLower(strings.TrimSpace(body.Kind)) - switch kind { - case "", "physical", "points", "xp", "points_xp": - if kind == "" { kind = "physical" } - default: - c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize kind"}); return - } - if body.Points < 0 { body.Points = 0 } - if body.XP < 0 { body.XP = 0 } - p := models.SweepstakePrize{ SweepstakeID: s.ID, Name: strings.TrimSpace(body.Name), Description: strings.TrimSpace(body.Description), ImageURL: strings.TrimSpace(body.ImageURL), Value: strings.TrimSpace(body.Value), Quantity: body.Quantity, DisplayOrder: body.DisplayOrder, Kind: kind, Points: body.Points, XP: body.XP } - if err := sc.DB.Create(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - c.JSON(http.StatusOK, p) + sid := strings.TrimSpace(c.Param("id")) + var s models.Sweepstake + if err := sc.DB.First(&s, sid).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Sweepstake not found"}) + return + } + var body struct { + Name string `json:"name"` + Description string `json:"description"` + ImageURL string `json:"image_url"` + Value string `json:"value"` + Quantity int `json:"quantity"` + DisplayOrder int `json:"display_order"` + Kind string `json:"kind"` + Points int64 `json:"points"` + XP int64 `json:"xp"` + } + if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + // Normalize prize kind/values + kind := strings.ToLower(strings.TrimSpace(body.Kind)) + switch kind { + case "", "physical", "points", "xp", "points_xp": + if kind == "" { + kind = "physical" + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid prize kind"}) + return + } + if body.Points < 0 { + body.Points = 0 + } + if body.XP < 0 { + body.XP = 0 + } + p := models.SweepstakePrize{SweepstakeID: s.ID, Name: strings.TrimSpace(body.Name), Description: strings.TrimSpace(body.Description), ImageURL: strings.TrimSpace(body.ImageURL), Value: strings.TrimSpace(body.Value), Quantity: body.Quantity, DisplayOrder: body.DisplayOrder, Kind: kind, Points: body.Points, XP: body.XP} + if err := sc.DB.Create(&p).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, p) } // Admin: update prize // PUT /api/v1/admin/sweepstakes/:id/prizes/:prize_id func (sc *SweepstakesController) AdminUpdatePrize(c *gin.Context) { - pid := strings.TrimSpace(c.Param("prize_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{"name":true,"description":true,"image_url":true,"value":true,"quantity":true,"display_order":true,"kind":true,"points":true,"xp":true} - upd := map[string]interface{}{} - for k,v := range body { if allowed[k] { upd[k] = v } } - // Validate kind if present - if v, ok := upd["kind"]; ok { - sv := strings.ToLower(strings.TrimSpace(toString(v))) - switch sv { - case "physical","points","xp","points_xp": - upd["kind"] = sv - default: - c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize kind"}); return - } - } - // Coerce points/xp to non-negative integers if present - if v, ok := upd["points"]; ok { upd["points"] = toNonNegInt64(v) } - if v, ok := upd["xp"]; ok { upd["xp"] = toNonNegInt64(v) } - if len(upd) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return } - if err := sc.DB.Model(&models.SweepstakePrize{}).Where("id = ?", pid).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - c.JSON(http.StatusOK, gin.H{"ok": true}) + pid := strings.TrimSpace(c.Param("prize_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{"name": true, "description": true, "image_url": true, "value": true, "quantity": true, "display_order": true, "kind": true, "points": true, "xp": true} + upd := map[string]interface{}{} + for k, v := range body { + if allowed[k] { + upd[k] = v + } + } + // Validate kind if present + if v, ok := upd["kind"]; ok { + sv := strings.ToLower(strings.TrimSpace(toString(v))) + switch sv { + case "physical", "points", "xp", "points_xp": + upd["kind"] = sv + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid prize kind"}) + return + } + } + // Coerce points/xp to non-negative integers if present + if v, ok := upd["points"]; ok { + upd["points"] = toNonNegInt64(v) + } + if v, ok := upd["xp"]; ok { + upd["xp"] = toNonNegInt64(v) + } + if len(upd) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"}) + return + } + if err := sc.DB.Model(&models.SweepstakePrize{}).Where("id = ?", pid).Updates(upd).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: delete prize // DELETE /api/v1/admin/sweepstakes/:id/prizes/:prize_id func (sc *SweepstakesController) AdminDeletePrize(c *gin.Context) { - pid := strings.TrimSpace(c.Param("prize_id")) - if err := sc.DB.Delete(&models.SweepstakePrize{}, "id = ?", pid).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - c.JSON(http.StatusOK, gin.H{"ok": true}) + pid := strings.TrimSpace(c.Param("prize_id")) + if err := sc.DB.Delete(&models.SweepstakePrize{}, "id = ?", pid).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: reorder prizes // POST /api/v1/admin/sweepstakes/:id/prizes/reorder { "order": [prize_id...] } func (sc *SweepstakesController) AdminReorderPrizes(c *gin.Context) { - sid := strings.TrimSpace(c.Param("id")) - var body struct{ Order []uint `json:"order"` } - if err := c.ShouldBindJSON(&body); err != nil || len(body.Order) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid order"}); return } - tx := sc.DB.Begin() - for i, id := range body.Order { - if err := tx.Model(&models.SweepstakePrize{}).Where("id = ? AND sweepstake_id = ?", id, sid).Update("display_order", i).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - } - if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - c.JSON(http.StatusOK, gin.H{"ok": true}) + sid := strings.TrimSpace(c.Param("id")) + var body struct { + Order []uint `json:"order"` + } + if err := c.ShouldBindJSON(&body); err != nil || len(body.Order) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid order"}) + return + } + tx := sc.DB.Begin() + for i, id := range body.Order { + if err := tx.Model(&models.SweepstakePrize{}).Where("id = ? AND sweepstake_id = ?", id, sid).Update("display_order", i).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + } + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: visualization data for sweepstake (participants and winners) // GET /api/v1/admin/sweepstakes/:id/visual func (sc *SweepstakesController) AdminVisualData(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 - } - // Winners in stable order - var winners []struct{ ID uint `json:"id"`; UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"`; ClaimStatus string `json:"claim_status"` } - _ = sc.DB.Table("sweepstake_winners").Select("id, user_id, prize_name, claim_status").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error - // Entries with display names and avatars - 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 + } + // Winners in stable order + var winners []struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + PrizeName string `json:"prize_name"` + ClaimStatus string `json:"claim_status"` + } + _ = sc.DB.Table("sweepstake_winners").Select("id, user_id, prize_name, claim_status").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error + // Entries with display names and avatars + 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: list sweepstakes with optional status filter func (sc *SweepstakesController) AdminList(c *gin.Context) { - 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}) + 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"` - 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) + 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,"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}) + 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}) + 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}) + 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}) + 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, - }) + 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) + myEntriesCount := int64(0) + canEnter := false + if uid, ok := c.Get("userID"); ok && uid != nil { + // Count valid entries for current user + _ = sc.DB.Model(&models.SweepstakeEntry{}).Where("sweepstake_id = ? AND user_id = ? AND status = ?", s.ID, uid.(uint), "valid").Count(&myEntriesCount).Error + hasEntered = myEntriesCount > 0 + // Determine if user can still enter (within time window and below per-user limit) + maxPer := s.MaxEntriesPerUser + if maxPer <= 0 { + maxPer = 1 + } + canEnter = now.After(s.StartAt) && now.Before(s.EndAt) && myEntriesCount < int64(maxPer) + // Keep the first entry's visual flag if exists + var e models.SweepstakeEntry + if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).Order("id ASC").First(&e).Error; err == nil { + visualPlayedAt = e.VisualPlayedAt + } + } + c.JSON(http.StatusOK, gin.H{ + "sweepstake": s, + "prizes": prizes, + "winners": winners, + "state": state, + "has_entered": hasEntered, + "visual_played_at": visualPlayedAt, + "my_entries_count": myEntriesCount, + "can_enter": canEnter, + }) } // Admin: list entries func (sc *SweepstakesController) AdminEntries(c *gin.Context) { id := c.Param("id") var items []models.SweepstakeEntry - if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } + if err := sc.DB.Where("sweepstake_id = ?", id).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}) } @@ -433,7 +645,10 @@ func (sc *SweepstakesController) AdminEntries(c *gin.Context) { func (sc *SweepstakesController) AdminWinners(c *gin.Context) { id := c.Param("id") var items []models.SweepstakeWinner - if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } + if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at ASC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } c.JSON(http.StatusOK, gin.H{"items": items}) } @@ -441,47 +656,81 @@ func (sc *SweepstakesController) AdminWinners(c *gin.Context) { func (sc *SweepstakesController) AdminFinalize(c *gin.Context) { id := 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 } - var body struct{ Seed string `json:"seed"` } + if err := sc.DB.First(&s, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + var body struct { + Seed string `json:"seed"` + } _ = c.ShouldBindJSON(&body) svc := services.NewSweepstakesService(sc.DB, sc.Email) - if err := svc.FinalizeSweepstake(&s, strings.TrimSpace(body.Seed)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to finalize"}); return } + if err := svc.FinalizeSweepstake(&s, strings.TrimSpace(body.Seed)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize"}) + return + } c.JSON(http.StatusOK, gin.H{"ok": true}) } // Helpers -func ifEmpty(v string, d string) string { if strings.TrimSpace(v)=="" { return d }; return strings.TrimSpace(v) } -func ifZero(v int, d int) int { if v==0 { return d }; return v } +func ifEmpty(v string, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return strings.TrimSpace(v) +} +func ifZero(v int, d int) int { + if v == 0 { + return d + } + return v +} // Helpers for update coercion func toString(v interface{}) string { - switch t := v.(type) { - case string: - return t - case []byte: - return string(t) - default: - return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(fmt.Sprintf("%v", v)), "\n", " ")) - } + switch t := v.(type) { + case string: + return t + case []byte: + return string(t) + default: + return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(fmt.Sprintf("%v", v)), "\n", " ")) + } } func toNonNegInt64(v interface{}) int64 { - switch n := v.(type) { - case int64: - if n < 0 { return 0 }; return n - case int: - if n < 0 { return 0 }; return int64(n) - case float64: - if n < 0 { return 0 }; return int64(n) - case float32: - if n < 0 { return 0 }; return int64(n) - case string: - if strings.TrimSpace(n) == "" { return 0 } - if f, err := strconv.ParseFloat(n, 64); err == nil { - if f < 0 { return 0 } - return int64(f) - } - return 0 - default: - return 0 - } + switch n := v.(type) { + case int64: + if n < 0 { + return 0 + } + return n + case int: + if n < 0 { + return 0 + } + return int64(n) + case float64: + if n < 0 { + return 0 + } + return int64(n) + case float32: + if n < 0 { + return 0 + } + return int64(n) + case string: + if strings.TrimSpace(n) == "" { + return 0 + } + if f, err := strconv.ParseFloat(n, 64); err == nil { + if f < 0 { + return 0 + } + return int64(f) + } + return 0 + default: + return 0 + } } diff --git a/internal/helpers/engagement_helpers.go b/internal/helpers/engagement_helpers.go index 5818274..e0fd6da 100644 --- a/internal/helpers/engagement_helpers.go +++ b/internal/helpers/engagement_helpers.go @@ -103,7 +103,7 @@ func GetRewardTypeDisplayName(rewardType string) string { "avatar_upload_unlock": "Odemknutí vlastního avataru", "merch_coupon": "Slevový kupon", "merch_physical": "Fyzické zboží", - "merch_digital": "Digitální produkt", + "merch_digital": "Digitální odměna", "custom": "Vlastní", } if name, ok := names[rewardType]; ok { diff --git a/internal/models/models.go b/internal/models/models.go index 371c417..caf0fed 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -88,17 +88,17 @@ type Article struct { 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) + 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", ...] + 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"` + 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"` + GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"` // YouTube video association (optional) YouTubeVideoID string `json:"youtube_video_id"` YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"` @@ -108,10 +108,10 @@ type Article struct { // Removed omitempty to always include in JSON (even if null) MatchLink *ArticleMatchLink `gorm:"-" json:"match_link"` // Computed helpers (not persisted) - CategorySlug string `gorm:"-" json:"category_slug,omitempty"` - CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"` - NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"` - URL string `gorm:"-" json:"url,omitempty"` + CategorySlug string `gorm:"-" json:"category_slug,omitempty"` + CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"` + NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"` + URL string `gorm:"-" json:"url,omitempty"` } // ArticleTeamLink represents a link from an article to a team identified by an external FACR ID @@ -143,7 +143,7 @@ type Team struct { ShortName string Description string LogoURL string `json:"logo_url"` - IsActive bool `gorm:"default:true"` + IsActive bool `gorm:"default:true"` } // Player represents a football player @@ -184,15 +184,15 @@ type Sponsor struct { // VideoTitleOverride represents a per-video title override (for auto YouTube source) type VideoTitleOverride struct { - VideoID string `json:"video_id"` - Title string `json:"title"` + 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"` + Label string `json:"label"` + URL string `json:"url"` + External bool `json:"external"` } type Settings struct { @@ -257,7 +257,7 @@ type Settings struct { // FrontendBaseURL: e.g. https://club.example.com FrontendBaseURL string `json:"frontend_base_url"` // APIBaseURL: full API root, e.g. https://api.example.com/api/v1 or https://backend.example.com/api/v1 - APIBaseURL string `json:"api_base_url"` + APIBaseURL string `json:"api_base_url"` // Social profiles FacebookURL string `json:"facebook_url"` @@ -279,10 +279,10 @@ type Settings struct { 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"` + 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"` + VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"` // Merch module configuration MerchModuleEnabled bool `json:"merch_module_enabled"` @@ -313,25 +313,25 @@ type Settings struct { NewsletterQuietEnd int `json:"newsletter_quiet_end"` // 0-23 // Contact/Location information for map - ContactAddress string `json:"contact_address"` - ContactCity string `json:"contact_city"` - ContactZip string `json:"contact_zip"` - ContactCountry string `json:"contact_country"` - ContactPhone string `json:"contact_phone"` - ContactEmail string `json:"contact_email"` + ContactAddress string `json:"contact_address"` + ContactCity string `json:"contact_city"` + ContactZip string `json:"contact_zip"` + ContactCountry string `json:"contact_country"` + ContactPhone string `json:"contact_phone"` + ContactEmail string `json:"contact_email"` // Contact form auto-forwarding - ContactForwardEnabled bool `json:"contact_forward_enabled"` - ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails - LocationLatitude float64 `json:"location_latitude"` - LocationLongitude float64 `json:"location_longitude"` - MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"` - MapStyle string `json:"map_style"` - ShowMapOnHomepage bool `json:"show_map_on_homepage"` + ContactForwardEnabled bool `json:"contact_forward_enabled"` + ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails + LocationLatitude float64 `json:"location_latitude"` + LocationLongitude float64 `json:"location_longitude"` + MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"` + MapStyle string `json:"map_style"` + ShowMapOnHomepage bool `json:"show_map_on_homepage"` // Homepage matches display configuration FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"` - StorageQuotaMB int `json:"storage_quota_mb"` - StorageWarnThreshold int `json:"storage_warn_threshold"` + StorageQuotaMB int `json:"storage_quota_mb"` + StorageWarnThreshold int `json:"storage_warn_threshold"` StorageCriticalThreshold int `json:"storage_critical_threshold"` // External error-review integration @@ -345,7 +345,6 @@ type Settings struct { // TableName specifies table name for Settings model func (Settings) TableName() string { return "settings" } - // LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string. func (s *Settings) LoadCustomNav() { if s.CustomNavJSON == "" { @@ -416,14 +415,14 @@ func (Club) TableName() string { // ContactCategory represents a category for organizing contacts (e.g., "Management", "Coaches", "Office") type ContactCategory struct { - ID uint `gorm:"primarykey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` - Name string `gorm:"not null;uniqueIndex" json:"name"` - Description string `json:"description"` - DisplayOrder int `gorm:"default:0" json:"display_order"` - IsActive bool `gorm:"default:true" json:"is_active"` + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` + Name string `gorm:"not null;uniqueIndex" json:"name"` + Description string `json:"description"` + DisplayOrder int `gorm:"default:0" json:"display_order"` + IsActive bool `gorm:"default:true" json:"is_active"` } // TableName specifies the table name for the ContactCategory model @@ -433,20 +432,20 @@ func (ContactCategory) TableName() string { // Contact represents a contact person (e.g., coach, manager, office staff) type Contact struct { - ID uint `gorm:"primarykey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` - CategoryID *uint `gorm:"index" json:"category_id,omitempty"` - Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - Name string `gorm:"not null" json:"name"` - Position string `json:"position"` // e.g., "Head Coach", "President" - Email string `json:"email"` - Phone string `json:"phone"` - ImageURL string `json:"image_url"` - Description string `gorm:"type:text" json:"description"` - DisplayOrder int `gorm:"default:0" json:"display_order"` - IsActive bool `gorm:"default:true" json:"is_active"` + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` + CategoryID *uint `gorm:"index" json:"category_id,omitempty"` + Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Name string `gorm:"not null" json:"name"` + Position string `json:"position"` // e.g., "Head Coach", "President" + Email string `json:"email"` + Phone string `json:"phone"` + ImageURL string `json:"image_url"` + Description string `gorm:"type:text" json:"description"` + DisplayOrder int `gorm:"default:0" json:"display_order"` + IsActive bool `gorm:"default:true" json:"is_active"` } // TableName specifies the table name for the Contact model diff --git a/internal/models/navigation.go b/internal/models/navigation.go index 9d71551..6cf0fc5 100644 --- a/internal/models/navigation.go +++ b/internal/models/navigation.go @@ -17,21 +17,24 @@ const ( // NavigationItem represents a single navigation menu item type NavigationItem struct { gorm.Model - Label string `gorm:"not null" json:"label"` - URL string `json:"url,omitempty"` - Icon string `json:"icon,omitempty"` - Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"` - PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar' - PageID *uint `json:"page_id,omitempty"` // optional reference to specific content - Visible bool `gorm:"not null;default:true" json:"visible"` - DisplayOrder int `gorm:"not null;default:0" json:"display_order"` - ParentID *uint `json:"parent_id,omitempty"` - Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"` - Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"` - Target string `gorm:"default:'_self'" json:"target"` // _self or _blank - CSSClass string `json:"css_class,omitempty"` - RequiresAuth bool `gorm:"default:false" json:"requires_auth"` - RequiresAdmin bool `gorm:"default:false" json:"requires_admin"` + Label string `gorm:"not null" json:"label"` + URL string `json:"url,omitempty"` + Icon string `json:"icon,omitempty"` + Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"` + PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar' + PageID *uint `json:"page_id,omitempty"` // optional reference to specific content + Visible bool `gorm:"not null;default:true" json:"visible"` + DisplayOrder int `gorm:"not null;default:0" json:"display_order"` + ParentID *uint `json:"parent_id,omitempty"` + Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Target string `gorm:"default:'_self'" json:"target"` // _self or _blank + CSSClass string `json:"css_class,omitempty"` + RequiresAuth bool `gorm:"default:false" json:"requires_auth"` + RequiresAdmin bool `gorm:"default:false" json:"requires_admin"` + // AllowEditor indicates that editors are allowed to access the corresponding admin page + // when this item represents an admin navigation entry (RequiresAdmin=true). + AllowEditor bool `gorm:"default:false" json:"allow_editor"` } // TableName specifies the table name for the NavigationItem model @@ -44,7 +47,7 @@ func (n *NavigationItem) GetURL() string { if n.URL != "" { return n.URL } - + // Map page types to URLs for frontend if n.Type == NavTypePage && n.PageType != "" { pageURLMap := map[string]string{ @@ -66,47 +69,47 @@ func (n *NavigationItem) GetURL() string { return url } } - + // Map admin page types to URLs if (n.Type == NavTypeInternal || n.Type == NavTypePage) && n.PageType != "" && n.RequiresAdmin { adminURLMap := map[string]string{ - "dashboard": "/admin", - "analytics": "/admin/analytika", - "teams": "/admin/tymy", - "matches": "/admin/zapasy", - "activities": "/admin/aktivity", - "players": "/admin/hraci", - "articles": "/admin/clanky", - "categories": "/admin/kategorie", - "about": "/admin/o-klubu", - "videos": "/admin/videa", - "gallery": "/admin/galerie", - "scoreboard": "/admin/scoreboard", - "scoreboard_remote": "/admin/scoreboard/remote", - "clothing": "/admin/obleceni", - "sponsors": "/admin/sponzori", - "banners": "/admin/bannery", - "messages": "/admin/zpravy", - "contacts": "/admin/kontakty", - "newsletter": "/admin/newsletter", - "polls": "/admin/ankety", - "comments": "/admin/komentare", - "sweepstakes": "/admin/sweepstakes", - "navigation": "/admin/navigace", + "dashboard": "/admin", + "analytics": "/admin/analytika", + "teams": "/admin/tymy", + "matches": "/admin/zapasy", + "activities": "/admin/aktivity", + "players": "/admin/hraci", + "articles": "/admin/clanky", + "categories": "/admin/kategorie", + "about": "/admin/o-klubu", + "videos": "/admin/videa", + "gallery": "/admin/galerie", + "scoreboard": "/admin/scoreboard", + "scoreboard_remote": "/admin/scoreboard/remote", + "clothing": "/admin/obleceni", + "sponsors": "/admin/sponzori", + "banners": "/admin/bannery", + "messages": "/admin/zpravy", + "contacts": "/admin/kontakty", + "newsletter": "/admin/newsletter", + "polls": "/admin/ankety", + "comments": "/admin/komentare", + "sweepstakes": "/admin/sweepstakes", + "navigation": "/admin/navigace", "competition_aliases": "/admin/aliasy-soutezi", - "prefetch": "/admin/prefetch", - "users": "/admin/uzivatele", - "settings": "/admin/nastaveni", - "shortlinks": "/admin/shortlinks", - "files": "/admin/soubory", - "docs": "/admin/docs", - "engagement": "/admin/engagement", + "prefetch": "/admin/prefetch", + "users": "/admin/uzivatele", + "settings": "/admin/nastaveni", + "shortlinks": "/admin/shortlinks", + "files": "/admin/soubory", + "docs": "/admin/docs", + "engagement": "/admin/engagement", } if url, ok := adminURLMap[n.PageType]; ok { return url } } - + return "#" } @@ -130,7 +133,7 @@ func (s *SocialLink) GetIconName() string { if s.Icon != "" { return s.Icon } - + iconMap := map[string]string{ "facebook": "FaFacebook", "instagram": "FaInstagram", @@ -141,10 +144,10 @@ func (s *SocialLink) GetIconName() string { "discord": "FaDiscord", "twitch": "FaTwitch", } - + if icon, ok := iconMap[s.Platform]; ok { return icon } - + return "FaLink" } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 5ba5349..b083562 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -189,6 +189,9 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) { editor.GET("/variants/:element_name", editorPreviewController.GetAvailableVariants) } + // Editor-allowed admin navigation (authenticated editors) + protected.GET("/admin/navigation/editor", middleware.RoleAuth("editor"), navigationController.GetEditorAllowedAdminNav) + // Newsletter preferences token for current user protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser) diff --git a/internal/services/newsletter_automation.go b/internal/services/newsletter_automation.go index 4f221f4..4ed6005 100644 --- a/internal/services/newsletter_automation.go +++ b/internal/services/newsletter_automation.go @@ -19,10 +19,10 @@ import ( // NewsletterAutomation handles all automated newsletter sending type NewsletterAutomation struct { - db *gorm.DB - emailSvc email.EmailService - cacheDir string - lastWeekly time.Time + db *gorm.DB + emailSvc email.EmailService + cacheDir string + lastWeekly time.Time lastMatchCheck time.Time } @@ -38,12 +38,12 @@ func NewNewsletterAutomation(db *gorm.DB, emailSvc email.EmailService) *Newslett // Start begins the newsletter automation loop func (na *NewsletterAutomation) Start() { log.Printf("[newsletter-automation] Starting automated newsletter service") - + // Run initial check after 1 minute time.AfterFunc(1*time.Minute, func() { na.RunCycle() }) - + // Then run every 15 minutes ticker := time.NewTicker(15 * time.Minute) go func() { @@ -59,18 +59,18 @@ func (na *NewsletterAutomation) RunCycle() { log.Printf("[newsletter-automation] Skipped: disabled in settings") return } - + log.Printf("[newsletter-automation] Running cycle...") - + // Check for weekly digest na.checkWeeklyDigest() - + // Check for upcoming matches (reminders) na.checkUpcomingMatches() - + // Check for finished matches (results) na.checkFinishedMatches() - + log.Printf("[newsletter-automation] Cycle complete") } @@ -79,40 +79,40 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er if !na.isEnabled() { return fmt.Errorf("newsletter automation is disabled") } - + // Check if already sent var existing models.BlogNotification if err := na.db.Where("article_id = ?", article.ID).First(&existing).Error; err == nil { log.Printf("[newsletter-automation] Blog notification already sent for article %d", article.ID) return nil } - + // Get subscribers interested in blogs subs := na.getSubscribersForType("blogs", article.CategoryName) if len(subs) == 0 { log.Printf("[newsletter-automation] No subscribers for blog notifications") return nil } - + // Build email content subject := fmt.Sprintf("Nový článek: %s", article.Title) baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") articleURL := fmt.Sprintf("%s/news/%s", baseFE, article.Slug) - + html := na.buildBlogNotificationHTML(article, articleURL) - + // Send to each subscriber recipients := make([]string, 0, len(subs)) for _, sub := range subs { recipients = append(recipients, sub.Email) } - + err := na.sendNewsletterToRecipients(recipients, subject, html, "blog_release") if err != nil { logger.Error("[newsletter-automation] Failed to send blog notification: %v", err) return err } - + // Record notification notif := models.BlogNotification{ ArticleID: article.ID, @@ -121,7 +121,7 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er CreatedAt: time.Now(), } na.db.Create(¬if) - + log.Printf("[newsletter-automation] Blog notification sent for article %d to %d recipients", article.ID, len(recipients)) return nil } @@ -129,11 +129,11 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er func (na *NewsletterAutomation) checkWeeklyDigest() { var settings models.Settings na.db.First(&settings) - + if !settings.EnableWeekly { return } - + // Get configured day and hour targetDay := strings.ToLower(strings.TrimSpace(settings.NewsletterWeeklyDay)) if targetDay == "" { @@ -143,47 +143,47 @@ func (na *NewsletterAutomation) checkWeeklyDigest() { if targetHour < 0 || targetHour > 23 { targetHour = 9 // Default to 9 AM } - + now := time.Now() currentDay := strings.ToLower(now.Weekday().String()[:3]) currentHour := now.Hour() - + // Check if it's the right day and hour if currentDay != targetDay || currentHour != targetHour { return } - + // Check if already sent today if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() { return } - + // Get all subscribers interested in weekly digest subs := na.getSubscribersForType("weekly", "") if len(subs) == 0 { log.Printf("[newsletter-automation] No subscribers for weekly digest") return } - + log.Printf("[newsletter-automation] Sending weekly digest to %d subscribers", len(subs)) - + // Build weekly content for each subscriber based on their preferences for _, sub := range subs { prefs := na.parsePreferences(sub) subject, html := BuildNewsletterDigest(na.cacheDir, prefs) - + if strings.TrimSpace(html) == "" { continue } - + err := na.sendNewsletterToRecipients([]string{sub.Email}, subject, html, "weekly") if err != nil { logger.Error("[newsletter-automation] Failed to send weekly digest to %s: %v", sub.Email, err) } - + time.Sleep(200 * time.Millisecond) // Rate limiting } - + na.lastWeekly = now log.Printf("[newsletter-automation] Weekly digest sent") } @@ -191,35 +191,57 @@ func (na *NewsletterAutomation) checkWeeklyDigest() { func (na *NewsletterAutomation) checkUpcomingMatches() { var settings models.Settings na.db.First(&settings) - - if !settings.EnableMatchReminders { - return - } - + + // Determine effective enabling: use settings, or auto-activate if there are subscribers and a match is within 2 hours + enabled := settings.EnableMatchReminders leadHours := settings.NewsletterReminderLeadHours if leadHours <= 0 { leadHours = 48 // Default 2 days } - + // Load match data from cache facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json")) matches := facrAllMatches(facr) - + now := time.Now() - + + if !enabled { + subs := na.getSubscribersForType("matches", "") + if len(subs) == 0 { + return + } + auto := false + for _, match := range matches { + matchTime := parseDateTimeISO(match.Date, match.Time) + if matchTime.IsZero() || matchTime.Before(now) { + continue + } + if matchTime.Sub(now).Hours() <= 2 { + auto = true + break + } + } + if !auto { + return + } + // Auto mode: restrict reminder window to 2 hours before kickoff + leadHours = 2 + enabled = true + } + for _, match := range matches { matchTime := parseDateTimeISO(match.Date, match.Time) if matchTime.IsZero() || matchTime.Before(now) { continue } - + hoursUntil := matchTime.Sub(now).Hours() - - // Check for 48h reminder + + // Check for lead-hour reminder (48h normally, 2h in auto mode) if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) { na.sendMatchReminder(match, "reminder_48h", leadHours) } - + // Check for day-of reminder (match starts in 0-6 hours) if hoursUntil <= 6 && hoursUntil > 0 { na.sendMatchReminder(match, "reminder_day", 0) @@ -234,13 +256,13 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil { return } - + // Get subscribers interested in matches and this competition subs := na.getSubscribersForType("matches", match.Competition) if len(subs) == 0 { return } - + // Build email content var subject string if notifType == "reminder_48h" { @@ -248,20 +270,20 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, } else { subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away) } - + html := na.buildMatchReminderHTML(match, notifType) - + recipients := make([]string, 0, len(subs)) for _, sub := range subs { recipients = append(recipients, sub.Email) } - + err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder") if err != nil { logger.Error("[newsletter-automation] Failed to send match reminder: %v", err) return } - + // Record notification notif := models.MatchNotification{ MatchID: matchKey, @@ -271,62 +293,91 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, CreatedAt: time.Now(), } na.db.Create(¬if) - + log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients)) } func (na *NewsletterAutomation) checkFinishedMatches() { var settings models.Settings na.db.First(&settings) - - if !settings.EnableResults { - return - } - - // Check quiet hours - currentHour := time.Now().Hour() - quietStart := settings.NewsletterQuietStart - quietEnd := settings.NewsletterQuietEnd - - if quietStart > 0 && quietEnd > 0 { - if quietStart < quietEnd { - // e.g., 22:00 - 08:00 - if currentHour >= quietStart || currentHour < quietEnd { - log.Printf("[newsletter-automation] In quiet hours, skipping result notifications") - return + + // Determine effective enabling. If disabled, auto-activate when there are subscribers and a recent result exists. + enabled := settings.EnableResults + + // Load match data + facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json")) + matches := facrAllMatches(facr) + + now := time.Now() + lookback := 6 * time.Hour // Check matches finished in last 6 hours + + bypassQuiet := false + if !enabled { + subs := na.getSubscribersForType("scores", "") + if len(subs) == 0 { + return + } + auto := false + for _, match := range matches { + if match.Score == "" || !strings.Contains(match.Score, ":") { + continue } - } else { - // e.g., 08:00 - 22:00 (inverted, send only during these hours) - if currentHour < quietStart && currentHour >= quietEnd { - log.Printf("[newsletter-automation] Outside active hours, skipping result notifications") + matchTime := parseDateTimeISO(match.Date, match.Time) + if matchTime.IsZero() || matchTime.After(now) { + continue + } + if now.Sub(matchTime) <= lookback { + auto = true + break + } + } + if !auto { + return + } + // Auto mode: send immediately when we have a result, ignoring quiet hours + bypassQuiet = true + enabled = true + } + + // Respect quiet hours only when explicitly enabled in settings (not in auto mode) + if !bypassQuiet { + currentHour := time.Now().Hour() + quietStart := settings.NewsletterQuietStart + quietEnd := settings.NewsletterQuietEnd + + // Consider quiet hours configured when both bounds are within 0..23 and not equal + if quietStart >= 0 && quietStart <= 23 && quietEnd >= 0 && quietEnd <= 23 && quietStart != quietEnd { + inQuiet := false + if quietStart < quietEnd { + // Same-day interval, e.g., 08:00–22:00 => quiet when between start and end + inQuiet = currentHour >= quietStart && currentHour < quietEnd + } else { + // Cross-midnight interval, e.g., 22:00–08:00 => quiet when hour >= start OR hour < end + inQuiet = currentHour >= quietStart || currentHour < quietEnd + } + if inQuiet { + log.Printf("[newsletter-automation] In quiet hours (%02d:00-%02d:00), skipping result notifications", quietStart, quietEnd) return } } } - - // Load match data - facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json")) - matches := facrAllMatches(facr) - - now := time.Now() - lookback := 6 * time.Hour // Check matches finished in last 6 hours - + for _, match := range matches { if match.Score == "" || !strings.Contains(match.Score, ":") { continue // No score yet } - + matchTime := parseDateTimeISO(match.Date, match.Time) if matchTime.IsZero() || matchTime.After(now) { continue } - + // Check if match finished recently timeSinceMatch := now.Sub(matchTime) if timeSinceMatch > lookback { continue } - + na.sendMatchResult(match) } } @@ -338,27 +389,27 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) { if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil { return } - + // Get subscribers interested in results subs := na.getSubscribersForType("scores", match.Competition) if len(subs) == 0 { return } - + subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away) html := na.buildMatchResultHTML(match) - + recipients := make([]string, 0, len(subs)) for _, sub := range subs { recipients = append(recipients, sub.Email) } - + err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result") if err != nil { logger.Error("[newsletter-automation] Failed to send match result: %v", err) return } - + // Record notification notif := models.MatchNotification{ MatchID: matchKey, @@ -368,7 +419,7 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) { CreatedAt: time.Now(), } na.db.Create(¬if) - + log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients)) } @@ -384,7 +435,7 @@ func (na *NewsletterAutomation) isEnabled() bool { func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription { var subs []models.NewsletterSubscription na.db.Where("is_active = ?", true).Find(&subs) - + filtered := make([]models.NewsletterSubscription, 0) for _, sub := range subs { // Check if subscriber wants this content type @@ -409,7 +460,7 @@ func (na *NewsletterAutomation) getSubscribersForType(contentType, category stri filtered = append(filtered, sub) } } - + return filtered } @@ -420,7 +471,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti Competitions: []string{}, Frequency: "daily", } - + // Parse content types if v, ok := sub.Preferences["blogs"].(bool); ok && v { prefs.ContentTypes = append(prefs.ContentTypes, "blogs") @@ -434,7 +485,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti if v, ok := sub.Preferences["scores"].(bool); ok && v { prefs.ContentTypes = append(prefs.ContentTypes, "scores") } - + // Parse categories/competitions if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" { for _, c := range strings.Split(cats, ",") { @@ -443,7 +494,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti } } } - + return prefs } @@ -453,12 +504,12 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string, Content: htmlContent, Recipients: recipients, } - + err := na.emailSvc.SendNewsletter(data) if err != nil { return err } - + // Log sent newsletter contentIDsJSON, _ := json.Marshal([]string{}) logEntry := models.NewsletterSentLog{ @@ -470,42 +521,44 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string, CreatedAt: time.Now(), } na.db.Create(&logEntry) - + return nil } func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string { - // Short description: prefer excerpt; otherwise derive from content - desc := strings.TrimSpace(article.Excerpt) - if desc == "" { - plain := utils.SanitizeString(article.Content) - if len(plain) > 260 { - cut := 240 - if cut < len(plain) { - for cut < len(plain) && plain[cut] != ' ' { - cut++ - } - } - if cut > len(plain) { cut = len(plain) } - plain = strings.TrimSpace(plain[:cut]) + "…" - } - desc = plain - } + // Short description: prefer excerpt; otherwise derive from content + desc := strings.TrimSpace(article.Excerpt) + if desc == "" { + plain := utils.SanitizeString(article.Content) + if len(plain) > 260 { + cut := 240 + if cut < len(plain) { + for cut < len(plain) && plain[cut] != ' ' { + cut++ + } + } + if cut > len(plain) { + cut = len(plain) + } + plain = strings.TrimSpace(plain[:cut]) + "…" + } + desc = plain + } - // Category badge (if available) - cat := strings.TrimSpace(article.CategoryName) - var catHTML string - if cat != "" { - catHTML = fmt.Sprintf(`