package services import ( "errors" "strings" "time" "gorm.io/gorm" "fotbal-club/internal/models" ) // EngagementService encapsulates points, XP, achievements, and rewards type EngagementService struct { DB *gorm.DB } // AwardPointsCapped applies simple anti-abuse caps per reason. // - poll_vote: max 1 award per day // - comment_create: max 10 awards per day // - newsletter_subscribe: once per lifetime func (s *EngagementService) AwardPointsCapped(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) { if userID == 0 || delta == 0 { return nil, nil } if !s.canAwardMore(userID, strings.TrimSpace(reason)) { return s.EnsureProfile(userID) // return current profile without adding } return s.AwardPoints(userID, delta, reason, meta) } func (s *EngagementService) canAwardMore(userID uint, reason string) bool { now := time.Now() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) switch reason { case "poll_vote": var cnt int64 _ = s.DB.Model(&models.PointsTransaction{}). Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "poll_vote", startOfDay). Count(&cnt).Error return cnt < 1 case "comment_create": var cnt int64 _ = s.DB.Model(&models.PointsTransaction{}). Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "comment_create", startOfDay). Count(&cnt).Error return cnt < 10 case "newsletter_subscribe": var cnt int64 _ = s.DB.Model(&models.PointsTransaction{}). Where("user_id = ? AND reason = ?", userID, "newsletter_subscribe"). Count(&cnt).Error return cnt == 0 default: return true } } func NewEngagementService(db *gorm.DB) *EngagementService { return &EngagementService{DB: db} } // EnsureProfile creates a profile if missing func (s *EngagementService) EnsureProfile(userID uint) (*models.UserProfile, error) { var up models.UserProfile if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { up = models.UserProfile{UserID: userID, Points: 0, Level: 1, XP: 0} if err := s.DB.Create(&up).Error; err != nil { return nil, err } return &up, nil } return nil, err } return &up, nil } // AwardPoints adds points/xp and logs transaction; returns updated profile func (s *EngagementService) AwardPoints(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) { if userID == 0 || delta == 0 { return nil, nil } if _, err := s.EnsureProfile(userID); err != nil { return nil, err } pt := models.PointsTransaction{ UserID: userID, Delta: delta, XPDelta: delta, Reason: strings.TrimSpace(reason) } if meta != nil { pt.Meta = meta } if err := s.DB.Create(&pt).Error; err != nil { return nil, err } // Update profile atomically if err := s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID). Updates(map[string]interface{}{ "points": gorm.Expr("points + ?", delta), "xp": gorm.Expr("xp + ?", delta), "updated_at": time.Now(), }).Error; err != nil { return nil, err } // Recompute level var up models.UserProfile if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil { return nil, err } lvl := ComputeLevel(up.XP) if lvl != up.Level { _ = s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("level", lvl).Error up.Level = lvl } return &up, nil } // ComputeLevel returns level for given XP (simple quadratic growth) func ComputeLevel(xp int64) int { // Level 1 at 0 xp, each level requires +100 * level xp increment approximately lvl := 1 threshold := int64(100) remaining := xp for remaining >= threshold { remaining -= threshold lvl++ threshold += int64(100) if lvl > 200 { break } } if lvl < 1 { lvl = 1 } return lvl } // CheckAndAwardAchievements evaluates basic achievements and awards when reached func (s *EngagementService) CheckAndAwardAchievements(userID uint) error { if userID == 0 { return nil } // Preload completed achievements for user var done []models.UserAchievement _ = s.DB.Where("user_id = ?", userID).Find(&done).Error doneSet := map[uint]bool{} for _, ua := range done { doneSet[ua.AchievementID] = true } // Ensure default achievements exist defaults := []models.Achievement{ {Code: "first_comment", Title: "První komentář", Description: "Napsal/a jste první komentář.", Points: 10, XP: 10, Active: true}, {Code: "first_vote", Title: "První hlasování", Description: "Poprvé jste hlasoval/a v anketě.", Points: 8, XP: 8, Active: true}, {Code: "newsletter_sub", Title: "Odběr novinek", Description: "Přihlášení k odběru newsletteru.", Points: 12, XP: 12, Active: true}, {Code: "comments_10", Title: "Komentátor", Description: "10 komentářů!", Points: 20, XP: 20, Active: true}, {Code: "votes_10", Title: "Hlasující", Description: "10 hlasování!", Points: 20, XP: 20, Active: true}, } for _, a := range defaults { var existing models.Achievement if err := s.DB.Where("code = ?", a.Code).First(&existing).Error; err != nil { _ = s.DB.Create(&a).Error } } // Compute counts var commentCount int64 _ = s.DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&commentCount).Error var voteCount int64 _ = s.DB.Model(&models.PollVote{}).Where("user_id = ?", userID).Count(&voteCount).Error var hasNewsletter bool if err := s.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; err != nil { // ignore } awardByCode := func(code string) { var a models.Achievement if err := s.DB.Where("code = ? AND active = ?", code, true).First(&a).Error; err == nil { var existing models.UserAchievement if err := s.DB.Where("user_id = ? AND achievement_id = ?", userID, a.ID).First(&existing).Error; errors.Is(err, gorm.ErrRecordNotFound) { _ = s.DB.Create(&models.UserAchievement{UserID: userID, AchievementID: a.ID}).Error _, _ = s.AwardPoints(userID, a.Points, "achievement:"+code, map[string]interface{}{"achievement_id": a.ID}) } } } if commentCount >= 1 { awardByCode("first_comment") } if voteCount >= 1 { awardByCode("first_vote") } if hasNewsletter { awardByCode("newsletter_sub") } if commentCount >= 10 { awardByCode("comments_10") } if voteCount >= 10 { awardByCode("votes_10") } return nil }