This commit is contained in:
Tomas Dvorak
2025-11-02 21:31:00 +01:00
parent b9cea0cd77
commit 087f30e82c
130 changed files with 20104 additions and 34330 deletions
+153 -41
View File
@@ -1,11 +1,16 @@
package services
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/datatypes"
"fotbal-club/internal/models"
)
@@ -21,38 +26,62 @@ type EngagementService struct {
// - 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)
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
}
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 < 3
case "comment_reacted":
var cnt int64
_ = s.DB.Model(&models.PointsTransaction{}).
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "comment_reacted", 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
case "daily_checkin":
var cnt int64
_ = s.DB.Model(&models.PointsTransaction{}).
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "daily_checkin", startOfDay).
Count(&cnt).Error
return cnt < 1
case "article_read":
var cnt int64
_ = s.DB.Model(&models.PointsTransaction{}).
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "article_read", startOfDay).
Count(&cnt).Error
return cnt < 3
case "newsletter_click":
var cnt int64
_ = s.DB.Model(&models.PointsTransaction{}).
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "newsletter_click", startOfDay).
Count(&cnt).Error
return cnt < 3
default:
return true
}
}
func NewEngagementService(db *gorm.DB) *EngagementService { return &EngagementService{DB: db} }
@@ -62,30 +91,112 @@ func (s *EngagementService) EnsureProfile(userID uint) (*models.UserProfile, err
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}
// Create with sensible defaults: level 1, 0 points/xp, autogenerated username and avatar
// Load basic user info for username suggestion
var u models.User
_ = s.DB.Select("first_name", "last_name", "email").First(&u, userID).Error
// Helper to generate a random hex suffix
randHex := func(n int) string {
b := make([]byte, n)
if _, e := rand.Read(b); e != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) }
return hex.EncodeToString(b)
}
// Build base username from name or email local part
base := strings.TrimSpace(strings.ToLower(strings.Join([]string{strings.TrimSpace(u.FirstName), strings.TrimSpace(u.LastName)}, " ")))
if base == "" && u.Email != "" {
if idx := strings.Index(u.Email, "@"); idx > 0 { base = u.Email[:idx] }
}
if base == "" { base = "fan" }
// Normalize to [a-z0-9-_]
norm := make([]rune, 0, len(base))
for _, r := range base {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
norm = append(norm, r)
} else if r == ' ' || r == '.' {
norm = append(norm, '-')
}
}
candidate := strings.Trim(strings.Trim(strings.ReplaceAll(string(norm), "--", "-"), "-"), "_")
if candidate == "" { candidate = "fan" }
// Ensure uniqueness by appending suffixes when needed
username := candidate
for i := 0; i < 5; i++ {
var cnt int64
_ = s.DB.Model(&models.UserProfile{}).Where("LOWER(username) = LOWER(?)", username).Count(&cnt).Error
if cnt == 0 { break }
username = fmt.Sprintf("%s-%s", candidate, randHex(2))
}
// Default avatar (Dicebear pixel-art)
seed := username
if seed == "" { seed = randHex(3) }
avatar := fmt.Sprintf("https://api.dicebear.com/7.x/pixel-art/svg?radius=50&seed=%s", url.QueryEscape(seed))
up = models.UserProfile{UserID: userID, Points: 0, Level: 1, XP: 0, Username: username, AvatarURL: avatar}
if err := s.DB.Create(&up).Error; err != nil { return nil, err }
return &up, nil
}
return nil, err
}
// Backfill missing fields for older profiles
changed := false
if strings.TrimSpace(up.Username) == "" {
var u models.User
_ = s.DB.Select("first_name", "last_name", "email").First(&u, userID).Error
base := strings.TrimSpace(strings.ToLower(strings.Join([]string{strings.TrimSpace(u.FirstName), strings.TrimSpace(u.LastName)}, " ")))
if base == "" && u.Email != "" { if idx := strings.Index(u.Email, "@"); idx > 0 { base = u.Email[:idx] } }
if base == "" { base = "fan" }
norm := make([]rune, 0, len(base))
for _, r := range base {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
norm = append(norm, r)
} else if r == ' ' || r == '.' { norm = append(norm, '-') }
}
candidate := strings.Trim(strings.Trim(strings.ReplaceAll(string(norm), "--", "-"), "-"), "_")
if candidate == "" { candidate = "fan" }
username := candidate
for i := 0; i < 5; i++ {
var cnt int64
_ = s.DB.Model(&models.UserProfile{}).Where("LOWER(username) = LOWER(?)", username).Count(&cnt).Error
if cnt == 0 { break }
username = fmt.Sprintf("%s-%d", candidate, time.Now().Unix()%10000)
}
up.Username = username
changed = true
}
if strings.TrimSpace(up.AvatarURL) == "" {
b := make([]byte, 3); _, _ = rand.Read(b)
seed := hex.EncodeToString(b)
up.AvatarURL = fmt.Sprintf("https://api.dicebear.com/7.x/pixel-art/svg?radius=50&seed=%s", url.QueryEscape(seed))
changed = true
}
if changed { _ = s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"username": up.Username, "avatar_url": up.AvatarURL}).Error }
return &up, nil
}
// AwardPoints adds points/xp and logs transaction; returns updated profile
// AwardPoints adds points/xp (xp mirrors points by default) 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 }
// admin_adjust should not affect XP
xpDelta := delta
if strings.TrimSpace(reason) == "admin_adjust" { xpDelta = 0 }
return s.AwardPointsAndXP(userID, delta, xpDelta, reason, meta)
}
// AwardPointsAndXP adds points and XP separately and logs transaction; returns updated profile
func (s *EngagementService) AwardPointsAndXP(userID uint, pointsDelta int64, xpDelta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
if userID == 0 || (pointsDelta == 0 && xpDelta == 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 }
pt := models.PointsTransaction{ UserID: userID, Delta: pointsDelta, XPDelta: xpDelta, Reason: strings.TrimSpace(reason) }
if meta != nil { pt.Meta = datatypes.JSONMap(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
updates := map[string]interface{}{
"updated_at": time.Now(),
}
if pointsDelta != 0 { updates["points"] = gorm.Expr("points + ?", pointsDelta) }
if xpDelta != 0 { updates["xp"] = gorm.Expr("xp + ?", xpDelta) }
if err := s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(updates).Error; err != nil { return nil, err }
// Recompute level based on XP
var up models.UserProfile
if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil { return nil, err }
lvl := ComputeLevel(up.XP)
@@ -152,7 +263,8 @@ func (s *EngagementService) CheckAndAwardAchievements(userID uint) error {
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})
// Award both points and XP as defined by achievement
_, _ = s.AwardPointsAndXP(userID, a.Points, a.XP, "achievement:"+code, map[string]interface{}{"achievement_id": a.ID})
}
}
}