Files
MyClub/internal/controllers/engagement_controller.go
T
Tomas Dvorak f3db65d350 dev day #90 🥳
2025-11-12 20:31:37 +01:00

972 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}