package controllers import ( "net/http" "strings" "strconv" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "gorm.io/datatypes" "fotbal-club/internal/models" "fotbal-club/internal/services" "fotbal-club/pkg/email" "fotbal-club/pkg/utils" ) type EngagementController struct { DB *gorm.DB Email email.EmailService } // 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{} } func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController { 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"` } if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 || body.Delta == 0 { 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 } if strings.TrimSpace(body.CurrentPassword) == "" { 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 } reason := strings.TrimSpace(body.Reason) 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 } // Re-check achievements opportunistically _ = svc.CheckAndAwardAchievements(body.UserID) c.JSON(http.StatusOK, gin.H{"ok": true}) } // GET /api/v1/engagement/profile (auth) func (ec *EngagementController) GetProfile(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(uint) svc := services.NewEngagementService(ec.DB) up, err := svc.EnsureProfile(userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load profile"}) return } 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, "animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked, "achievements": achCount, "engagement_disabled": c.GetString("userRole") == "admin", }) } // PATCH /api/v1/engagement/profile (auth) – update username func (ec *EngagementController) PatchProfile(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(uint) var body struct { Username *string `json:"username"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } if body.Username == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"}) return } uname := strings.TrimSpace(*body.Username) if uname == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Uživatelské jméno nesmí být prázdné"}) return } if len(uname) > 32 { c.JSON(http.StatusBadRequest, gin.H{"error": "Maximální délka je 32 znaků"}) return } for _, r := range uname { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { continue } c.JSON(http.StatusBadRequest, gin.H{"error": "Povolena jsou pouze malá písmena, čísla a znaky -_."}) return } var cnt int64 if err := ec.DB.Model(&models.UserProfile{}).Where("LOWER(username) = LOWER(?) AND user_id <> ?", uname, userID).Count(&cnt).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } if cnt > 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Uživatelské jméno je již obsazené"}) return } if err := ec.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("username", uname).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update username"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } 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"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } if body.AvatarURL == nil && body.AnimatedAvatarURL == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"}) return } // Ensure profile exists and load unlock flags svc := services.NewEngagementService(ec.DB) up, _ := svc.EnsureProfile(userID) updates := map[string]interface{}{} if body.AvatarURL != nil { url := strings.TrimSpace(*body.AvatarURL) if url == "" { updates["avatar_url"] = "" } else { // Custom uploads require unlock if strings.HasPrefix(url, "/uploads") && !up.AvatarUploadUnlocked { c.JSON(http.StatusForbidden, gin.H{"error": "Není odemčeno nahrávání vlastního avataru"}) return } updates["avatar_url"] = url } } if body.AnimatedAvatarURL != nil { url := strings.TrimSpace(*body.AnimatedAvatarURL) if url == "" { updates["animated_avatar_url"] = "" } else { if strings.HasPrefix(url, "/uploads") && !up.AnimatedAvatarUploadUnlocked { c.JSON(http.StatusForbidden, gin.H{"error": "Není odemčeno nahrávání animovaného avataru"}) return } updates["animated_avatar_url"] = url } } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"}) return } if err := ec.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update avatar"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // 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) } // POST /api/v1/engagement/redeem (auth) func (ec *EngagementController) Redeem(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(uint) // Admins cannot redeem rewards if c.GetString("userRole") == "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Admins cannot redeem rewards"}) return } var body struct { RewardID uint `json:"reward_id"` } if err := c.ShouldBindJSON(&body); err != nil || body.RewardID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } var item models.RewardItem if err := ec.DB.First(&item, body.RewardID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Reward not found"}) return } if !item.Active { c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not active"}) return } // Check validity window (metadata.valid_from/valid_to or expires_at) 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 v, ok := item.Metadata["valid_to"]; ok { 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 } } now := time.Now() if startPtr != nil && now.Before(*startPtr) { c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not currently available"}) return } if endPtr != nil && now.After(*endPtr) { c.JSON(http.StatusBadRequest, gin.H{"error": "Reward validity has ended"}) return } } if item.Stock == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"}) return } svc := services.NewEngagementService(ec.DB) up, err := svc.EnsureProfile(userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load profile"}) return } if up.Points < item.CostPoints { c.JSON(http.StatusBadRequest, gin.H{"error": "Nedostatek bodů"}) return } tx := ec.DB.Begin() if res := tx.Model(&models.UserProfile{}).Where("user_id = ? AND points >= ?", userID, item.CostPoints).UpdateColumn("points", gorm.Expr("points - ?", item.CostPoints)); res.Error != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deduct points"}) return } else if res.RowsAffected == 0 { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{"error": "Nedostatek bodů"}) return } if item.Stock > 0 { if res := tx.Model(&models.RewardItem{}).Where("id = ? AND stock > 0", item.ID).UpdateColumn("stock", gorm.Expr("stock - 1")); res.Error != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update stock"}) return } else if res.RowsAffected == 0 { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"}) return } } red := models.RewardRedemption{ UserID: userID, RewardID: item.ID, Status: "approved", } if strings.HasPrefix(item.Type, "merch_") || item.Type == "custom" { red.Status = "pending" } if err := tx.Create(&red).Error; err != nil { tx.Rollback() 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 if item.Type == "avatar_static" { _ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("avatar_url", item.ImageURL).Error } if item.Type == "avatar_animated" { _ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("animated_avatar_url", item.ImageURL).Error } if item.Type == "avatar_upload_unlock" { _ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("avatar_upload_unlocked", true).Error } if item.Type == "avatar_animated_upload_unlock" { _ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("animated_avatar_upload_unlocked", true).Error } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit redemption"}) return } // Emails var user models.User _ = ec.DB.First(&user, userID).Error redeemedAt := time.Now().Format(time.RFC3339) if strings.TrimSpace(user.Email) != "" && ec.Email != nil { _ = ec.Email.SendEmail(&email.EmailData{ Subject: "Potvrzení uplatnění odměny", 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, "UserFirstName": strings.TrimSpace(user.FirstName), "UserLastName": strings.TrimSpace(user.LastName), "UserEmail": strings.TrimSpace(user.Email), }, }) } if red.Status == "pending" && ec.Email != nil { var set models.Settings _ = ec.DB.First(&set).Error ownerEmail := strings.TrimSpace(set.ContactEmail) 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" } } fullName := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName)) _ = ec.Email.SendEmail(&email.EmailData{ Subject: "Nové uplatnění odměny – čeká na vyřízení", To: []string{ownerEmail}, Template: "reward_redeemed_admin", Data: map[string]interface{}{ "RewardName": item.Name, "RewardType": item.Type, "Points": item.CostPoints, "Status": red.Status, "RedeemedAt": redeemedAt, "UserID": user.ID, "UserFull": fullName, "UserEmail": strings.TrimSpace(user.Email), "ManageURL": manageURL, }, }) } } c.JSON(http.StatusOK, gin.H{"ok": true, "status": red.Status}) } // GET /api/v1/engagement/achievements (auth) func (ec *EngagementController) GetAchievements(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(uint) svc := services.NewEngagementService(ec.DB) _ = svc.CheckAndAwardAchievements(userID) var defs []models.Achievement _ = ec.DB.Where("active = ?", true).Order("id ASC").Find(&defs).Error var userAch []models.UserAchievement _ = ec.DB.Where("user_id = ?", userID).Find(&userAch).Error achieved := map[uint]models.UserAchievement{} for _, ua := range userAch { achieved[ua.AchievementID] = ua } var commentCount int64 _ = ec.DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&commentCount).Error var voteCount int64 _ = ec.DB.Model(&models.PollVote{}).Where("user_id = ?", userID).Count(&voteCount).Error hasNewsletter := false _ = ec.DB.Model(&models.NewsletterSubscription{}).Select("1").Where("LOWER(email) = (SELECT LOWER(email) FROM users WHERE id = ?) AND is_active = ?", userID, true).Limit(1).Scan(&hasNewsletter).Error items := make([]gin.H, 0, len(defs)) for _, d := range defs { if ua, ok := achieved[d.ID]; ok { items = append(items, gin.H{ "id": d.ID, "code": d.Code, "title": d.Title, "description": d.Description, "points": d.Points, "xp": d.XP, "icon": d.Icon, "achieved": true, "achieved_at": ua.CreatedAt, }) } else { items = append(items, gin.H{ "id": d.ID, "code": d.Code, "title": d.Title, "description": d.Description, "points": d.Points, "xp": d.XP, "icon": d.Icon, "achieved": false, }) } } c.JSON(http.StatusOK, gin.H{"achievements": items, "counters": gin.H{"comments": commentCount, "votes": voteCount, "newsletter": hasNewsletter}}) } // 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}) } // 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) } // 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}) } // 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}) } // 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}) } // 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}) } // 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}) } // 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}) } // 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}) } // 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 } } } 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 } 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 } } } 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}) } // 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, }) } // 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}) }