mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
972 lines
39 KiB
Go
972 lines
39 KiB
Go
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})
|
||
}
|