Files
MyClub/internal/controllers/engagement_controller.go
T
Tomas Dvorak 087f30e82c dev day #80
2025-11-02 21:31:00 +01:00

831 lines
33 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"
)
type EngagementController struct {
DB *gorm.DB
Email email.EmailService
}
// 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) {
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,
})
}
// 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
}
updates := map[string]interface{}{}
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.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
}
c.JSON(http.StatusOK, items)
}
// POST /api/v1/engagement/redeem (auth)
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 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
}
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
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 }
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")
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})
}