mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
dev day #79
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user