This commit is contained in:
Tomas Dvorak
2025-11-02 21:31:00 +01:00
parent b9cea0cd77
commit 087f30e82c
130 changed files with 20104 additions and 34330 deletions
+652 -92
View File
@@ -3,17 +3,312 @@ 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"
)
type EngagementController struct{ DB *gorm.DB }
type EngagementController struct {
DB *gorm.DB
Email email.EmailService
}
func NewEngagementController(db *gorm.DB) *EngagementController { return &EngagementController{DB: db} }
// POST /api/v1/engagement/checkin (auth)
// Awards daily check-in points (cap 1/day via service caps)
func (ec *EngagementController) Checkin(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
// 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)
// Body: { "article_id": <id> }
// Awards small points for unique article reads (cap 3/day + dedupe per article)
func (ec *EngagementController) ArticleRead(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
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) {
// already awarded for this article
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/transactions (auth)
// Query: limit (default 50, max 200), reason?
func (ec *EngagementController) GetMyTransactions(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
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})
}
// 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})
}
// 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"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 || body.Delta == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); 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/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})
}
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
return &EngagementController{DB: db, Email: es}
}
// GET /api/v1/engagement/profile (auth)
func (ec *EngagementController) GetProfile(c *gin.Context) {
@@ -21,8 +316,10 @@ func (ec *EngagementController) GetProfile(c *gin.Context) {
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 }
// Achievements count
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{
@@ -30,33 +327,155 @@ func (ec *EngagementController) GetProfile(c *gin.Context) {
"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,
})
}
// 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})
}
// PATCH /api/v1/engagement/avatar (auth)
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 }
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
}
updates := map[string]interface{}{}
if body.AvatarURL != nil { updates["avatar_url"] = strings.TrimSpace(*body.AvatarURL) }
if body.AnimatedAvatarURL != nil { updates["animated_avatar_url"] = strings.TrimSpace(*body.AnimatedAvatarURL) }
if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if body.AvatarURL != nil {
url := strings.TrimSpace(*body.AvatarURL)
if strings.HasPrefix(url, "/uploads/") {
var up models.UserProfile
if err := ec.DB.Where("user_id = ?", userID).First(&up).Error; err == nil {
if !up.AvatarUploadUnlocked {
c.JSON(http.StatusForbidden, gin.H{"error": "Nahrání vlastního avataru je uzamčeno. Odemkněte v obchodě."})
return
}
}
}
updates["avatar_url"] = url
}
if body.AnimatedAvatarURL != nil {
url := strings.TrimSpace(*body.AnimatedAvatarURL)
if strings.HasPrefix(url, "/uploads/") {
var up models.UserProfile
if err := ec.DB.Where("user_id = ?", userID).First(&up).Error; err == nil {
if !up.AnimatedAvatarUploadUnlocked {
c.JSON(http.StatusForbidden, gin.H{"error": "Nahrání vlastního animovaného avataru je uzamčeno. Odemkněte v obchodě."})
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.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: 250,
ImageURL: "",
Stock: -1,
Active: true,
}
_ = ec.DB.Create(&unlock).Error
} else {
if !unlock.Active {
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Update("active", true).Error
}
}
// Ensure a small default catalog exists with generic icons (DiceBear) admins can adjust later.
defaults := []models.RewardItem{
{ Name: "Avatar Modrý #1", Type: "avatar_static", CostPoints: 50, ImageURL: "https://api.dicebear.com/7.x/adventurer-neutral/svg?seed=FC-1", Stock: -1, Active: true },
{ Name: "Avatar Červený #2", Type: "avatar_static", CostPoints: 50, ImageURL: "https://api.dicebear.com/7.x/adventurer-neutral/svg?seed=FC-2", Stock: -1, Active: true },
{ Name: "Avatar Zelený #3", Type: "avatar_static", CostPoints: 50, ImageURL: "https://api.dicebear.com/7.x/adventurer-neutral/svg?seed=FC-3", Stock: -1, Active: true },
{ Name: "Odemknout animovaný avatar (upload)", Type: "avatar_animated_upload_unlock", CostPoints: 150, ImageURL: "", Stock: -1, Active: true },
{ Name: "Vlastní (generovaný)", Type: "custom", CostPoints: 150, ImageURL: "https://api.dicebear.com/7.x/shapes/svg?seed=Custom1", Stock: -1, Active: true },
{ Name: "Sleva na eshop", Type: "merch_coupon", CostPoints: 1700, ImageURL: "https://api.dicebear.com/7.x/icons/svg?seed=Shop", Stock: -1, Active: true },
{ Name: "Fyzická odměna", Type: "merch_physical", CostPoints: 4000, ImageURL: "https://api.dicebear.com/7.x/icons/svg?seed=GiftBox", Stock: -1, Active: true },
}
for _, d := range defaults {
var existing models.RewardItem
if err := ec.DB.Where("name = ?", d.Name).First(&existing).Error; err != nil {
_ = ec.DB.Create(&d).Error
} else if !existing.Active {
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", existing.ID).Update("active", true).Error
}
}
var items []models.RewardItem
q := ec.DB.Where("active = ?", true)
if err := q.Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load rewards"}); return }
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
c.JSON(http.StatusOK, items)
}
@@ -64,100 +483,189 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
func (ec *EngagementController) Redeem(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
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 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 }
if item.Stock == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Out of stock"}); return }
// Ensure profile
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
}
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 }
// Transaction: deduct points, reduce stock, create redemption
tx := ec.DB.Begin()
if err := tx.Model(&models.UserProfile{}).Where("user_id = ? AND points >= ?", userID, item.CostPoints).UpdateColumn("points", gorm.Expr("points - ?", item.CostPoints)).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to deduct points"}); return }
if item.Stock > 0 {
if err := tx.Model(&models.RewardItem{}).Where("id = ? AND stock > 0", item.ID).UpdateColumn("stock", gorm.Expr("stock - 1")).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update stock"}); return }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load profile"})
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 }
// If avatar reward, update profile immediately
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 err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to commit redemption"}); return }
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)
// Ensure defaults and award any newly satisfied achievements
svc := services.NewEngagementService(ec.DB)
_ = svc.CheckAndAwardAchievements(userID)
// Load active achievement definitions
var defs []models.Achievement
_ = ec.DB.Where("active = ?", true).Order("id ASC").Find(&defs).Error
// Load user's completed achievements
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 }
// Counters for progress
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
// Build response
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,
},
})
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
@@ -263,8 +771,60 @@ func (ec *EngagementController) AdminUpdateRedemptionStatus(c *gin.Context) {
default:
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return
}
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
// 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})
}