mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #80
This commit is contained in:
@@ -129,6 +129,11 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure engagement profile exists with autogenerated username & avatar
|
||||
// Best-effort: ignore error, profile will be created lazily if needed
|
||||
trySvc := services.NewEngagementService(ac.DB)
|
||||
_, _ = trySvc.EnsureProfile(user.ID)
|
||||
|
||||
// Auto-subscribe newly registered fans to the newsletter
|
||||
_ = models.SubscribeToNewsletter(ac.DB, user.Email)
|
||||
|
||||
|
||||
@@ -2433,79 +2433,7 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
// Trigger background prefetch and YouTube cache refresh when settings are updated post-setup
|
||||
go func(snap models.Settings) {
|
||||
defer func() { _ = recover() }()
|
||||
snap.LoadCustomNav()
|
||||
var pubVids []string
|
||||
if snap.VideosJSON != "" {
|
||||
_ = json.Unmarshal([]byte(snap.VideosJSON), &pubVids)
|
||||
}
|
||||
var pubVidsItems any
|
||||
if snap.VideosItemsJSON != "" {
|
||||
_ = json.Unmarshal([]byte(snap.VideosItemsJSON), &pubVidsItems)
|
||||
}
|
||||
var pubMerchItems any
|
||||
if snap.MerchItemsJSON != "" {
|
||||
_ = json.Unmarshal([]byte(snap.MerchItemsJSON), &pubMerchItems)
|
||||
}
|
||||
resp := map[string]any{
|
||||
"club_id": snap.ClubID,
|
||||
"club_type": snap.ClubType,
|
||||
"club_name": snap.ClubName,
|
||||
"club_logo_url": snap.ClubLogoURL,
|
||||
"club_url": snap.ClubURL,
|
||||
"primary_color": snap.PrimaryColor,
|
||||
"secondary_color": snap.SecondaryColor,
|
||||
"accent_color": snap.AccentColor,
|
||||
"background_color": snap.BackgroundColor,
|
||||
"text_color": snap.TextColor,
|
||||
"font_heading": snap.FontHeading,
|
||||
"font_body": snap.FontBody,
|
||||
"sponsors_layout": snap.SponsorsLayout,
|
||||
"sponsors_theme": snap.SponsorsTheme,
|
||||
"facebook_url": snap.FacebookURL,
|
||||
"instagram_url": snap.InstagramURL,
|
||||
"youtube_url": snap.YoutubeURL,
|
||||
"gallery_url": snap.GalleryURL,
|
||||
"gallery_label": snap.GalleryLabel,
|
||||
"videos_module_enabled": snap.VideosModuleEnabled,
|
||||
"videos_style": snap.VideosStyle,
|
||||
"videos_source": snap.VideosSource,
|
||||
"videos_limit": snap.VideosLimit,
|
||||
"videos": pubVids,
|
||||
"videos_items": pubVidsItems,
|
||||
"merch_module_enabled": snap.MerchModuleEnabled,
|
||||
"merch_style": snap.MerchStyle,
|
||||
"merch_source": snap.MerchSource,
|
||||
"merch_limit": snap.MerchLimit,
|
||||
"merch_items": pubMerchItems,
|
||||
"about_html": snap.AboutHTML,
|
||||
"show_about_in_nav": snap.ShowAboutInNav,
|
||||
"custom_nav": snap.CustomNav,
|
||||
"contact_address": snap.ContactAddress,
|
||||
"contact_city": snap.ContactCity,
|
||||
"contact_zip": snap.ContactZip,
|
||||
"contact_country": snap.ContactCountry,
|
||||
"contact_phone": snap.ContactPhone,
|
||||
"contact_email": snap.ContactEmail,
|
||||
"location_latitude": snap.LocationLatitude,
|
||||
"location_longitude": snap.LocationLongitude,
|
||||
"map_zoom_level": snap.MapZoomLevel,
|
||||
"map_style": snap.MapStyle,
|
||||
"show_map_on_homepage": snap.ShowMapOnHomepage,
|
||||
}
|
||||
b, _ := json.MarshalIndent(resp, "", " ")
|
||||
outPath := filepath.Join("cache", "prefetch", "settings.json")
|
||||
_ = os.MkdirAll(filepath.Dir(outPath), 0o755)
|
||||
tmp := outPath + ".tmp"
|
||||
_ = os.WriteFile(tmp, b, 0o644)
|
||||
_ = os.Rename(tmp, outPath)
|
||||
}(s)
|
||||
{
|
||||
base := strings.TrimSpace(s.APIBaseURL)
|
||||
if base == "" { base = getPrefetchBaseURL() }
|
||||
go services.PrefetchOnce(strings.TrimRight(base, "/"))
|
||||
}
|
||||
go func() { services.PrefetchOnce(getBaseURL()) }()
|
||||
if strings.TrimSpace(s.YoutubeURL) != "" {
|
||||
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
|
||||
}
|
||||
@@ -3061,6 +2989,11 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
|
||||
// Deployment base URLs (optional, for domain/IP change)
|
||||
FrontendBaseURL *string `json:"frontend_base_url"`
|
||||
APIBaseURL *string `json:"api_base_url"`
|
||||
|
||||
// Storage quota and thresholds
|
||||
StorageQuotaMB *int `json:"storage_quota_mb"`
|
||||
StorageWarnThreshold *int `json:"storage_warn_threshold"`
|
||||
StorageCriticalThreshold *int `json:"storage_critical_threshold"`
|
||||
}
|
||||
var body reqBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -3289,6 +3222,17 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Storage quota and thresholds
|
||||
if body.StorageQuotaMB != nil {
|
||||
s.StorageQuotaMB = *body.StorageQuotaMB
|
||||
}
|
||||
if body.StorageWarnThreshold != nil {
|
||||
s.StorageWarnThreshold = *body.StorageWarnThreshold
|
||||
}
|
||||
if body.StorageCriticalThreshold != nil {
|
||||
s.StorageCriticalThreshold = *body.StorageCriticalThreshold
|
||||
}
|
||||
|
||||
// SMTP dynamic settings (if provided)
|
||||
if body.SMTPHost != nil {
|
||||
s.SMTPHost = strings.TrimSpace(*body.SMTPHost)
|
||||
|
||||
@@ -15,6 +15,29 @@ import (
|
||||
|
||||
type CommentController struct{ DB *gorm.DB }
|
||||
|
||||
// Admin: list active bans
|
||||
// GET /api/v1/admin/comments/bans
|
||||
func (cc *CommentController) AdminListBans(c *gin.Context) {
|
||||
var bans []models.CommentBan
|
||||
// Active = until is NULL (permanent) OR until > now
|
||||
now := time.Now()
|
||||
if err := cc.DB.Where("until IS NULL OR until > ?", now).Order("created_at DESC").Find(&bans).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load bans"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": bans})
|
||||
}
|
||||
|
||||
// Admin: lift a ban early by setting until = now
|
||||
// POST /api/v1/admin/comments/bans/:id/lift
|
||||
func (cc *CommentController) AdminLiftBan(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
now := time.Now()
|
||||
if err := cc.DB.Model(&models.CommentBan{}).Where("id = ?", id).Update("until", now).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to lift ban"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ReportComment allows a user to report a comment with an optional reason
|
||||
func (cc *CommentController) ReportComment(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
@@ -59,6 +82,9 @@ func (cc *CommentController) React(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
|
||||
return
|
||||
}
|
||||
// Award a small amount of points for reactions (capped per day in service)
|
||||
svc := services.NewEngagementService(cc.DB)
|
||||
_, _ = svc.AwardPointsCapped(uid.(uint), 1, "comment_reacted", map[string]interface{}{"comment_id": cm.ID})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -196,8 +222,8 @@ type userSlim struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
@@ -217,7 +243,6 @@ func toOutput(c models.Comment) commentOutput {
|
||||
ID: c.User.ID,
|
||||
FirstName: c.User.FirstName,
|
||||
LastName: c.User.LastName,
|
||||
Email: c.User.Email,
|
||||
Role: c.User.Role,
|
||||
},
|
||||
SpamScore: c.SpamScore,
|
||||
@@ -291,25 +316,24 @@ func (cc *CommentController) GetComments(c *gin.Context) {
|
||||
for _, r := range rs { myReactions[r.CommentID] = r.Type }
|
||||
}
|
||||
}
|
||||
// Preload user profiles for avatar (prefer animated when available)
|
||||
avatarByUser := map[uint]string{}
|
||||
// Preload user profiles for username + avatar (prefer animated when available)
|
||||
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string; Username string }
|
||||
profByUser := map[uint]up{}
|
||||
if len(userIDs) > 0 {
|
||||
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string }
|
||||
var profs []up
|
||||
_ = cc.DB.Table("user_profiles").Select("user_id, avatar_url, animated_avatar_url").Where("user_id IN ?", userIDs).Scan(&profs).Error
|
||||
_ = cc.DB.Table("user_profiles").Select("user_id, avatar_url, animated_avatar_url, username").Where("user_id IN ?", userIDs).Scan(&profs).Error
|
||||
for _, p := range profs {
|
||||
if strings.TrimSpace(p.AnimatedAvatarURL) != "" {
|
||||
avatarByUser[p.UserID] = p.AnimatedAvatarURL
|
||||
} else {
|
||||
avatarByUser[p.UserID] = p.AvatarURL
|
||||
}
|
||||
profByUser[p.UserID] = p
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
co := toOutput(r)
|
||||
if co.User.ID != 0 {
|
||||
if av, ok := avatarByUser[co.User.ID]; ok { co.User.AvatarURL = av }
|
||||
if p, ok := profByUser[co.User.ID]; ok {
|
||||
if strings.TrimSpace(p.Username) != "" { co.User.Username = p.Username }
|
||||
if strings.TrimSpace(p.AnimatedAvatarURL) != "" { co.User.AvatarURL = p.AnimatedAvatarURL } else { co.User.AvatarURL = p.AvatarURL }
|
||||
}
|
||||
}
|
||||
if rc, ok := reactionCounts[r.ID]; ok { co.Reactions = rc } else { co.Reactions = map[string]int{} }
|
||||
if myReactions != nil { if t, ok := myReactions[r.ID]; ok { co.MyReaction = t } }
|
||||
@@ -395,10 +419,10 @@ func (cc *CommentController) CreateComment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Award engagement points for visible comment
|
||||
// Award engagement points for visible comment (capped per day)
|
||||
if status == "visible" {
|
||||
svc := services.NewEngagementService(cc.DB)
|
||||
_, _ = svc.AwardPoints(userID, 5, "comment_create", map[string]interface{}{"comment_id": cm.ID})
|
||||
_, _ = svc.AwardPointsCapped(userID, 5, "comment_create", map[string]interface{}{"comment_id": cm.ID})
|
||||
_ = svc.CheckAndAwardAchievements(userID)
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,43 @@ func (cc *ContactController) SubmitContactForm(c *gin.Context) {
|
||||
UserAgent: ua,
|
||||
})
|
||||
|
||||
// Auto-forward to saved recipients if enabled
|
||||
var set models.Settings
|
||||
if err := cc.DB.First(&set).Error; err == nil && set.ContactForwardEnabled {
|
||||
recipients := make([]string, 0)
|
||||
for _, part := range strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) {
|
||||
v := strings.TrimSpace(part)
|
||||
if v != "" {
|
||||
recipients = append(recipients, v)
|
||||
}
|
||||
}
|
||||
if len(recipients) > 0 {
|
||||
forwardData := &email.EmailData{
|
||||
Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", subject),
|
||||
To: recipients,
|
||||
Template: "contact_form",
|
||||
Data: struct {
|
||||
Name string
|
||||
Email string
|
||||
Subject string
|
||||
Message string
|
||||
Time string
|
||||
IP string
|
||||
Agent string
|
||||
}{
|
||||
Name: name,
|
||||
Email: emailStr,
|
||||
Subject: subject,
|
||||
Message: message,
|
||||
Time: time.Now().Format(time.RFC1123Z),
|
||||
IP: ip,
|
||||
Agent: ua,
|
||||
},
|
||||
}
|
||||
_ = cc.emailService.SendEmail(forwardData)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,16 @@ func (ec *EmailController) ClickRedirect(c *gin.Context) {
|
||||
"subject": log.Subject,
|
||||
"url": target,
|
||||
})
|
||||
// Engagement: award for newsletter clicks only
|
||||
if strings.ToLower(strings.TrimSpace(log.Type)) == "newsletter" {
|
||||
var u models.User
|
||||
if err := ec.DB.Where("LOWER(email) = LOWER(?)", strings.TrimSpace(log.RecipientEmail)).First(&u).Error; err == nil && u.ID != 0 {
|
||||
svc := services.NewEngagementService(ec.DB)
|
||||
// Cap enforced in service (newsletter_click: max 3/day)
|
||||
_, _ = svc.AwardPointsCapped(u.ID, 3, "newsletter_click", map[string]interface{}{"url": target, "email_log_id": log.ID})
|
||||
_ = svc.CheckAndAwardAchievements(u.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 e‑shop", 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})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/email"
|
||||
)
|
||||
|
||||
type SweepstakesController struct {
|
||||
DB *gorm.DB
|
||||
Email email.EmailService
|
||||
}
|
||||
|
||||
// Public: visualization data for a specific sweepstake (within visibility window)
|
||||
// GET /api/v1/sweepstakes/:id/visual
|
||||
func (sc *SweepstakesController) PublicVisualData(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
var s models.Sweepstake
|
||||
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
|
||||
now := time.Now()
|
||||
if s.VisibilityUntil == nil || now.After(*s.VisibilityUntil) || now.Before(s.EndAt) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error":"Not available"}); return
|
||||
}
|
||||
var winners []struct{ UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"` }
|
||||
_ = sc.DB.Table("sweepstake_winners").Select("user_id, prize_name").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
|
||||
type entryRow struct {
|
||||
UserID uint `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
var entries []entryRow
|
||||
q := sc.DB.Table("sweepstake_entries AS e").
|
||||
Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url").
|
||||
Joins("JOIN users u ON u.id = e.user_id").
|
||||
Joins("LEFT JOIN user_profiles up ON up.user_id = u.id").
|
||||
Where("e.sweepstake_id = ?", id)
|
||||
_ = q.Scan(&entries).Error
|
||||
c.JSON(http.StatusOK, gin.H{ "sweepstake": s, "entries": entries, "winners": winners })
|
||||
}
|
||||
|
||||
// Admin: set or change prize for a specific winner
|
||||
// PATCH /api/v1/admin/sweepstakes/:id/winners/:winner_id/prize { "prize_id": 123 }
|
||||
func (sc *SweepstakesController) AdminSetWinnerPrize(c *gin.Context) {
|
||||
wid := strings.TrimSpace(c.Param("winner_id"))
|
||||
var body struct{ PrizeID uint `json:"prize_id"` }
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.PrizeID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize"}); return }
|
||||
// Load prize name
|
||||
var p models.SweepstakePrize
|
||||
if err := sc.DB.First(&p, body.PrizeID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Prize not found"}); return }
|
||||
updates := map[string]interface{}{ "prize_id": p.ID, "prize_name": strings.TrimSpace(p.Name) }
|
||||
if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Admin: update winner status (claim/delivered/pending)
|
||||
// PATCH /api/v1/admin/sweepstakes/:id/winners/:winner_id { "claim_status": "claimed|delivered|pending", "claim_note":"..." }
|
||||
func (sc *SweepstakesController) AdminUpdateWinner(c *gin.Context) {
|
||||
wid := strings.TrimSpace(c.Param("winner_id"))
|
||||
var body struct{
|
||||
ClaimStatus string `json:"claim_status"`
|
||||
ClaimNote string `json:"claim_note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
|
||||
st := strings.ToLower(strings.TrimSpace(body.ClaimStatus))
|
||||
if st == "" { st = "pending" }
|
||||
switch st {
|
||||
case "pending","claimed","delivered":
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid status"}); return
|
||||
}
|
||||
// Load winner to evaluate prize awarding
|
||||
var w models.SweepstakeWinner
|
||||
if err := sc.DB.First(&w, wid).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
|
||||
// Update fields
|
||||
updates := map[string]interface{}{ "claim_status": st }
|
||||
if strings.TrimSpace(body.ClaimNote) != "" { updates["claim_note"] = strings.TrimSpace(body.ClaimNote) }
|
||||
// Award non-physical prizes only once when moving to claimed/delivered
|
||||
shouldAward := (st == "claimed" || st == "delivered") && (w.AwardedAt == nil)
|
||||
if shouldAward && w.PrizeID != nil {
|
||||
var p models.SweepstakePrize
|
||||
if err := sc.DB.First(&p, *w.PrizeID).Error; err == nil {
|
||||
if p.Kind == "points" || p.Kind == "xp" || p.Kind == "points_xp" {
|
||||
svc := services.NewEngagementService(sc.DB)
|
||||
var pointsDelta, xpDelta int64
|
||||
switch p.Kind {
|
||||
case "points": pointsDelta, xpDelta = p.Points, 0
|
||||
case "xp": pointsDelta, xpDelta = 0, p.XP
|
||||
case "points_xp": pointsDelta, xpDelta = p.Points, p.XP
|
||||
}
|
||||
if pointsDelta != 0 || xpDelta != 0 {
|
||||
_, _ = svc.AwardPointsAndXP(w.UserID, pointsDelta, xpDelta, "sweepstake_prize", map[string]interface{}{"prize_id": p.ID, "sweepstake_id": w.SweepstakeID})
|
||||
}
|
||||
now := time.Now()
|
||||
updates["awarded_at"] = &now
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Admin: list prizes
|
||||
// GET /api/v1/admin/sweepstakes/:id/prizes
|
||||
func (sc *SweepstakesController) AdminListPrizes(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var items []models.SweepstakePrize
|
||||
if err := sc.DB.Where("sweepstake_id = ?", id).Order("display_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// Admin: create prize
|
||||
// POST /api/v1/admin/sweepstakes/:id/prizes
|
||||
func (sc *SweepstakesController) AdminCreatePrize(c *gin.Context) {
|
||||
sid := strings.TrimSpace(c.Param("id"))
|
||||
var s models.Sweepstake
|
||||
if err := sc.DB.First(&s, sid).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Sweepstake not found"}); return }
|
||||
var body struct{
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Value string `json:"value"`
|
||||
Quantity int `json:"quantity"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
Kind string `json:"kind"`
|
||||
Points int64 `json:"points"`
|
||||
XP int64 `json:"xp"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
|
||||
}
|
||||
// Normalize prize kind/values
|
||||
kind := strings.ToLower(strings.TrimSpace(body.Kind))
|
||||
switch kind {
|
||||
case "", "physical", "points", "xp", "points_xp":
|
||||
if kind == "" { kind = "physical" }
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize kind"}); return
|
||||
}
|
||||
if body.Points < 0 { body.Points = 0 }
|
||||
if body.XP < 0 { body.XP = 0 }
|
||||
p := models.SweepstakePrize{ SweepstakeID: s.ID, Name: strings.TrimSpace(body.Name), Description: strings.TrimSpace(body.Description), ImageURL: strings.TrimSpace(body.ImageURL), Value: strings.TrimSpace(body.Value), Quantity: body.Quantity, DisplayOrder: body.DisplayOrder, Kind: kind, Points: body.Points, XP: body.XP }
|
||||
if err := sc.DB.Create(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// Admin: update prize
|
||||
// PUT /api/v1/admin/sweepstakes/:id/prizes/:prize_id
|
||||
func (sc *SweepstakesController) AdminUpdatePrize(c *gin.Context) {
|
||||
pid := strings.TrimSpace(c.Param("prize_id"))
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
|
||||
allowed := map[string]bool{"name":true,"description":true,"image_url":true,"value":true,"quantity":true,"display_order":true,"kind":true,"points":true,"xp":true}
|
||||
upd := map[string]interface{}{}
|
||||
for k,v := range body { if allowed[k] { upd[k] = v } }
|
||||
// Validate kind if present
|
||||
if v, ok := upd["kind"]; ok {
|
||||
sv := strings.ToLower(strings.TrimSpace(toString(v)))
|
||||
switch sv {
|
||||
case "physical","points","xp","points_xp":
|
||||
upd["kind"] = sv
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize kind"}); return
|
||||
}
|
||||
}
|
||||
// Coerce points/xp to non-negative integers if present
|
||||
if v, ok := upd["points"]; ok { upd["points"] = toNonNegInt64(v) }
|
||||
if v, ok := upd["xp"]; ok { upd["xp"] = toNonNegInt64(v) }
|
||||
if len(upd) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
|
||||
if err := sc.DB.Model(&models.SweepstakePrize{}).Where("id = ?", pid).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Admin: delete prize
|
||||
// DELETE /api/v1/admin/sweepstakes/:id/prizes/:prize_id
|
||||
func (sc *SweepstakesController) AdminDeletePrize(c *gin.Context) {
|
||||
pid := strings.TrimSpace(c.Param("prize_id"))
|
||||
if err := sc.DB.Delete(&models.SweepstakePrize{}, "id = ?", pid).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Admin: reorder prizes
|
||||
// POST /api/v1/admin/sweepstakes/:id/prizes/reorder { "order": [prize_id...] }
|
||||
func (sc *SweepstakesController) AdminReorderPrizes(c *gin.Context) {
|
||||
sid := strings.TrimSpace(c.Param("id"))
|
||||
var body struct{ Order []uint `json:"order"` }
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Order) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid order"}); return }
|
||||
tx := sc.DB.Begin()
|
||||
for i, id := range body.Order {
|
||||
if err := tx.Model(&models.SweepstakePrize{}).Where("id = ? AND sweepstake_id = ?", id, sid).Update("display_order", i).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Admin: visualization data for sweepstake (participants and winners)
|
||||
// GET /api/v1/admin/sweepstakes/:id/visual
|
||||
func (sc *SweepstakesController) AdminVisualData(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
var s models.Sweepstake
|
||||
if err := sc.DB.First(&s, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return
|
||||
}
|
||||
// Winners in stable order
|
||||
var winners []struct{ ID uint `json:"id"`; UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"`; ClaimStatus string `json:"claim_status"` }
|
||||
_ = sc.DB.Table("sweepstake_winners").Select("id, user_id, prize_name, claim_status").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
|
||||
// Entries with display names and avatars
|
||||
type entryRow struct {
|
||||
UserID uint `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
var entries []entryRow
|
||||
q := sc.DB.Table("sweepstake_entries AS e").
|
||||
Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url").
|
||||
Joins("JOIN users u ON u.id = e.user_id").
|
||||
Joins("LEFT JOIN user_profiles up ON up.user_id = u.id").
|
||||
Where("e.sweepstake_id = ?", id)
|
||||
_ = q.Scan(&entries).Error
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sweepstake": s,
|
||||
"entries": entries,
|
||||
"winners": winners,
|
||||
})
|
||||
}
|
||||
|
||||
func NewSweepstakesController(db *gorm.DB, es email.EmailService) *SweepstakesController {
|
||||
return &SweepstakesController{DB: db, Email: es}
|
||||
}
|
||||
|
||||
// Public: get current visible sweepstake (upcoming/active/finalized within visibility window)
|
||||
func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
|
||||
now := time.Now()
|
||||
var s models.Sweepstake
|
||||
q := sc.DB.Where("start_at <= ? AND (visibility_until IS NULL OR visibility_until >= ?)", now, now).
|
||||
Order("start_at DESC")
|
||||
if err := q.First(&s).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"sweepstake": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load sweepstake"})
|
||||
return
|
||||
}
|
||||
// Compute state
|
||||
state := "upcoming"
|
||||
if now.After(s.StartAt) && now.Before(s.EndAt) {
|
||||
state = "active"
|
||||
} else if now.After(s.EndAt) {
|
||||
state = "finalized"
|
||||
}
|
||||
// Load prizes (public info)
|
||||
var prizes []models.SweepstakePrize
|
||||
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Order("display_order ASC, id ASC").Find(&prizes).Error
|
||||
// Winners if selected
|
||||
var winners []models.SweepstakeWinner
|
||||
if s.WinnersSelectedAt != nil {
|
||||
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Find(&winners).Error
|
||||
}
|
||||
// Current user status
|
||||
hasEntered := false
|
||||
visualPlayedAt := (*time.Time)(nil)
|
||||
if uid, ok := c.Get("userID"); ok && uid != nil {
|
||||
var e models.SweepstakeEntry
|
||||
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).First(&e).Error; err == nil {
|
||||
hasEntered = true
|
||||
visualPlayedAt = e.VisualPlayedAt
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sweepstake": s,
|
||||
"prizes": prizes,
|
||||
"winners": winners,
|
||||
"state": state,
|
||||
"has_entered": hasEntered,
|
||||
"visual_played_at": visualPlayedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Protected: enter/join sweepstake (idempotent)
|
||||
func (sc *SweepstakesController) Enter(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
// Load sweepstake
|
||||
var s models.Sweepstake
|
||||
if err := sc.DB.First(&s, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return
|
||||
}
|
||||
now := time.Now()
|
||||
if !(now.After(s.StartAt) && now.Before(s.EndAt)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Soutěž není aktivní"}); return
|
||||
}
|
||||
// Idempotent create
|
||||
entry := models.SweepstakeEntry{ SweepstakeID: s.ID, UserID: userID, Status: "valid" }
|
||||
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, userID).FirstOrCreate(&entry).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Protected: mark visualization as played (only once)
|
||||
func (sc *SweepstakesController) MarkVisualPlayed(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
var s models.Sweepstake
|
||||
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
|
||||
now := time.Now()
|
||||
if !(now.After(s.EndAt) && (s.VisibilityUntil == nil || now.Before(*s.VisibilityUntil))) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Vizualizace není dostupná"}); return
|
||||
}
|
||||
_ = sc.DB.Model(&models.SweepstakeEntry{}).
|
||||
Where("sweepstake_id = ? AND user_id = ? AND visual_played_at IS NULL", s.ID, userID).
|
||||
Updates(map[string]interface{}{"visual_played_at": time.Now(), "view_count": gorm.Expr("view_count + 1")}).Error
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Protected: my wins list
|
||||
func (sc *SweepstakesController) MyWinnings(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
var wins []models.SweepstakeWinner
|
||||
if err := sc.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&wins).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": wins})
|
||||
}
|
||||
|
||||
// Admin: list sweepstakes
|
||||
func (sc *SweepstakesController) AdminList(c *gin.Context) {
|
||||
var items []models.Sweepstake
|
||||
q := sc.DB.Model(&models.Sweepstake{})
|
||||
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"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// Admin: create sweepstake
|
||||
func (sc *SweepstakesController) AdminCreate(c *gin.Context) {
|
||||
var body struct{
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
RulesURL string `json:"rules_url"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt time.Time `json:"end_at"`
|
||||
PickerStyle string `json:"picker_style"`
|
||||
TotalPrizes int `json:"total_prizes"`
|
||||
PrizeSummary string `json:"prize_summary"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Title) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
|
||||
}
|
||||
item := models.Sweepstake{
|
||||
Title: strings.TrimSpace(body.Title),
|
||||
Description: strings.TrimSpace(body.Description),
|
||||
ImageURL: strings.TrimSpace(body.ImageURL),
|
||||
RulesURL: strings.TrimSpace(body.RulesURL),
|
||||
StartAt: body.StartAt, EndAt: body.EndAt,
|
||||
PickerStyle: ifEmpty(body.PickerStyle, "wheel"),
|
||||
TotalPrizes: ifZero(body.TotalPrizes, 1),
|
||||
PrizeSummary: strings.TrimSpace(body.PrizeSummary),
|
||||
Status: "scheduled",
|
||||
}
|
||||
if time.Now().After(item.StartAt) { item.Status = "active" }
|
||||
if err := sc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create"}); return }
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// Admin: update sweepstake
|
||||
func (sc *SweepstakesController) AdminUpdate(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
|
||||
allowed := map[string]bool{"title":true,"description":true,"image_url":true,"rules_url":true,"start_at":true,"end_at":true,"picker_style":true,"total_prizes":true,"prize_summary":true,"status":true}
|
||||
upd := map[string]interface{}{}
|
||||
for k,v := range body { if allowed[k] { upd[k] = v } }
|
||||
if len(upd)==0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
|
||||
if err := sc.DB.Model(&models.Sweepstake{}).Where("id = ?", id).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Admin: delete sweepstake
|
||||
func (sc *SweepstakesController) AdminDelete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := sc.DB.Delete(&models.Sweepstake{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Admin: list entries
|
||||
func (sc *SweepstakesController) AdminEntries(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var items []models.SweepstakeEntry
|
||||
if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// Admin: list winners
|
||||
func (sc *SweepstakesController) AdminWinners(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var items []models.SweepstakeWinner
|
||||
if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// Admin: finalize (pick winners now)
|
||||
func (sc *SweepstakesController) AdminFinalize(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var s models.Sweepstake
|
||||
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
|
||||
var body struct{ Seed string `json:"seed"` }
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
svc := services.NewSweepstakesService(sc.DB, sc.Email)
|
||||
if err := svc.FinalizeSweepstake(&s, strings.TrimSpace(body.Seed)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to finalize"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Helpers
|
||||
func ifEmpty(v string, d string) string { if strings.TrimSpace(v)=="" { return d }; return strings.TrimSpace(v) }
|
||||
func ifZero(v int, d int) int { if v==0 { return d }; return v }
|
||||
|
||||
// Helpers for update coercion
|
||||
func toString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case []byte:
|
||||
return string(t)
|
||||
default:
|
||||
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(fmt.Sprintf("%v", v)), "\n", " "))
|
||||
}
|
||||
}
|
||||
func toNonNegInt64(v interface{}) int64 {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
if n < 0 { return 0 }; return n
|
||||
case int:
|
||||
if n < 0 { return 0 }; return int64(n)
|
||||
case float64:
|
||||
if n < 0 { return 0 }; return int64(n)
|
||||
case float32:
|
||||
if n < 0 { return 0 }; return int64(n)
|
||||
case string:
|
||||
if strings.TrimSpace(n) == "" { return 0 }
|
||||
if f, err := strconv.ParseFloat(n, 64); err == nil {
|
||||
if f < 0 { return 0 }
|
||||
return int64(f)
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user