mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #81
This commit is contained in:
@@ -1258,7 +1258,16 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
||||
|
||||
if art.Published && !oldPublished {
|
||||
go bc.triggerBlogNotification(&art)
|
||||
go func() { services.PrefetchOnce(getBaseURL()) }()
|
||||
go func() {
|
||||
var s models.Settings
|
||||
if err := bc.DB.First(&s).Error; err == nil {
|
||||
base := strings.TrimSpace(s.APIBaseURL)
|
||||
if base == "" { base = getPrefetchBaseURL() }
|
||||
services.PrefetchOnce(strings.TrimRight(base, "/"))
|
||||
} else {
|
||||
services.PrefetchOnce(getPrefetchBaseURL())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
|
||||
@@ -2396,9 +2405,19 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
||||
s.SMTPPassword = v
|
||||
}
|
||||
if v := strings.TrimSpace(body.SMTP.From); v != "" {
|
||||
s.SMTPFrom = v
|
||||
name := ""
|
||||
addr := v
|
||||
if lt, gt := strings.Index(v, "<"), strings.Index(v, ">"); lt >= 0 && gt > lt {
|
||||
name = strings.TrimSpace(v[:lt])
|
||||
addr = strings.TrimSpace(v[lt+1 : gt])
|
||||
}
|
||||
addr = strings.Trim(addr, "\" ")
|
||||
name = strings.Trim(name, "\" ")
|
||||
s.SMTPFrom = addr
|
||||
if name != "" && !strings.Contains(strings.ToLower(name), "@") {
|
||||
s.SMTPFromName = name
|
||||
}
|
||||
}
|
||||
// Default FromName if empty
|
||||
if s.SMTPFromName == "" {
|
||||
s.SMTPFromName = "Fotbal Club"
|
||||
}
|
||||
@@ -2432,8 +2451,81 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
||||
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", s.ID).Update("club_logo_url", url).Error
|
||||
}
|
||||
}
|
||||
// Trigger background prefetch and YouTube cache refresh when settings are updated post-setup
|
||||
go func() { services.PrefetchOnce(getBaseURL()) }()
|
||||
// Immediately write public settings cache from current Settings snapshot
|
||||
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)
|
||||
// Trigger background prefetch using APIBaseURL if set, otherwise fallback to local
|
||||
go func(urlFromSettings string) {
|
||||
base := strings.TrimSpace(urlFromSettings)
|
||||
if base == "" { base = getPrefetchBaseURL() }
|
||||
services.PrefetchOnce(strings.TrimRight(base, "/"))
|
||||
}(s.APIBaseURL)
|
||||
if strings.TrimSpace(s.YoutubeURL) != "" {
|
||||
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
|
||||
}
|
||||
@@ -2703,9 +2795,19 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
||||
s.SMTPPassword = v
|
||||
}
|
||||
if v := strings.TrimSpace(body.SMTP.From); v != "" {
|
||||
s.SMTPFrom = v
|
||||
name := ""
|
||||
addr := v
|
||||
if lt, gt := strings.Index(v, "<"), strings.Index(v, ">"); lt >= 0 && gt > lt {
|
||||
name = strings.TrimSpace(v[:lt])
|
||||
addr = strings.TrimSpace(v[lt+1 : gt])
|
||||
}
|
||||
addr = strings.Trim(addr, "\" ")
|
||||
name = strings.Trim(name, "\" ")
|
||||
s.SMTPFrom = addr
|
||||
if name != "" && !strings.Contains(strings.ToLower(name), "@") {
|
||||
s.SMTPFromName = name
|
||||
}
|
||||
}
|
||||
// Default FromName if empty
|
||||
if s.SMTPFromName == "" {
|
||||
s.SMTPFromName = "Fotbal Club"
|
||||
}
|
||||
@@ -3458,6 +3560,8 @@ func (bc *BaseController) GetPublicSettings(c *gin.Context) {
|
||||
"club_name": s.ClubName,
|
||||
"club_logo_url": s.ClubLogoURL,
|
||||
"club_url": s.ClubURL,
|
||||
// Runtime flags (env-based)
|
||||
"premium": config.AppConfig.Premium,
|
||||
|
||||
// Theme
|
||||
"primary_color": s.PrimaryColor,
|
||||
@@ -3722,6 +3826,7 @@ func (bc *BaseController) CreatePlayer(c *gin.Context) {
|
||||
Height *int `json:"height"`
|
||||
Weight *int `json:"weight"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Gender string `json:"gender"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -3736,6 +3841,7 @@ func (bc *BaseController) CreatePlayer(c *gin.Context) {
|
||||
Position: strings.TrimSpace(body.Position),
|
||||
Nationality: strings.TrimSpace(body.Nationality),
|
||||
ImageURL: strings.TrimSpace(body.ImageURL),
|
||||
Gender: strings.TrimSpace(body.Gender),
|
||||
IsActive: true,
|
||||
Email: strings.TrimSpace(body.Email),
|
||||
}
|
||||
@@ -3796,6 +3902,7 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
|
||||
Height *int `json:"height"`
|
||||
Weight *int `json:"weight"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
Gender *string `json:"gender"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/email"
|
||||
"fotbal-club/pkg/utils"
|
||||
)
|
||||
|
||||
type EngagementController struct {
|
||||
@@ -20,150 +21,35 @@ type EngagementController struct {
|
||||
Email email.EmailService
|
||||
}
|
||||
|
||||
// POST /api/v1/engagement/checkin (auth)
|
||||
// Awards daily check-in points (cap 1/day via service caps)
|
||||
func (ec *EngagementController) Checkin(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
// Fast check if already checked in today
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var cnt int64
|
||||
_ = ec.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "daily_checkin", startOfDay).
|
||||
Count(&cnt).Error
|
||||
already := cnt > 0
|
||||
svc := services.NewEngagementService(ec.DB)
|
||||
if !already {
|
||||
_, _ = svc.AwardPointsCapped(userID, 8, "daily_checkin", map[string]interface{}{"at": now.Format(time.RFC3339)})
|
||||
}
|
||||
// Ensure profile for response
|
||||
up, _ := svc.EnsureProfile(userID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": !already, "points": up.Points, "level": up.Level, "xp": up.XP})
|
||||
}
|
||||
|
||||
// POST /api/v1/engagement/article-read (auth)
|
||||
// Body: { "article_id": <id> }
|
||||
// Awards small points for unique article reads (cap 3/day + dedupe per article)
|
||||
func (ec *EngagementController) ArticleRead(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
var body struct{ ArticleID uint `json:"article_id"` }
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ArticleID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
// Dedupe per article: check recent transactions with meta.article_id == body.ArticleID
|
||||
var txs []models.PointsTransaction
|
||||
_ = ec.DB.Where("user_id = ? AND reason = ?", userID, "article_read").Order("created_at DESC").Limit(200).Find(&txs).Error
|
||||
for _, t := range txs {
|
||||
if t.Meta != nil {
|
||||
if v, ok := t.Meta["article_id"]; ok {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(vv) == strconv.FormatUint(uint64(body.ArticleID), 10) {
|
||||
// already awarded for this article
|
||||
svc := services.NewEngagementService(ec.DB)
|
||||
up, _ := svc.EnsureProfile(userID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP})
|
||||
return
|
||||
}
|
||||
case float64:
|
||||
if uint(vv) == body.ArticleID {
|
||||
svc := services.NewEngagementService(ec.DB)
|
||||
up, _ := svc.EnsureProfile(userID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
svc := services.NewEngagementService(ec.DB)
|
||||
_, _ = svc.AwardPointsCapped(userID, 2, "article_read", map[string]interface{}{"article_id": strconv.FormatUint(uint64(body.ArticleID), 10)})
|
||||
up, _ := svc.EnsureProfile(userID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": true, "points": up.Points, "level": up.Level, "xp": up.XP})
|
||||
}
|
||||
|
||||
// GET /api/v1/engagement/transactions (auth)
|
||||
// Query: limit (default 50, max 200), reason?
|
||||
func (ec *EngagementController) GetMyTransactions(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
limit := 50
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
if n > 0 && n <= 200 { limit = n }
|
||||
}
|
||||
}
|
||||
q := ec.DB.Model(&models.PointsTransaction{}).Where("user_id = ?", userID)
|
||||
if r := strings.TrimSpace(c.Query("reason")); r != "" {
|
||||
q = q.Where("reason = ?", r)
|
||||
}
|
||||
var items []models.PointsTransaction
|
||||
if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load transactions"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/engagement/profile/:user_id (admin)
|
||||
func (ec *EngagementController) AdminGetUserProfile(c *gin.Context) {
|
||||
userIDStr := strings.TrimSpace(c.Param("user_id"))
|
||||
if userIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error":"user_id required"}); return }
|
||||
var up models.UserProfile
|
||||
if err := ec.DB.Where("user_id = ?", userIDStr).First(&up).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error":"Profile not found"}); return
|
||||
}
|
||||
// Optionally include user basic info
|
||||
var u models.User
|
||||
_ = ec.DB.Select("id, first_name, last_name, email, role").Where("id = ?", userIDStr).First(&u).Error
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": up.UserID,
|
||||
"first_name": strings.TrimSpace(u.FirstName),
|
||||
"last_name": strings.TrimSpace(u.LastName),
|
||||
"email": strings.TrimSpace(u.Email),
|
||||
"role": u.Role,
|
||||
"points": up.Points,
|
||||
"level": up.Level,
|
||||
"xp": up.XP,
|
||||
"username": up.Username,
|
||||
"avatar_url": up.AvatarURL,
|
||||
"animated_avatar_url": up.AnimatedAvatarURL,
|
||||
"avatar_upload_unlocked": up.AvatarUploadUnlocked,
|
||||
"animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked,
|
||||
})
|
||||
}
|
||||
|
||||
// Admin: list points transactions with optional filters
|
||||
// GET /api/v1/admin/engagement/transactions?user_id=&reason=&limit=
|
||||
func (ec *EngagementController) AdminListTransactions(c *gin.Context) {
|
||||
q := ec.DB.Model(&models.PointsTransaction{})
|
||||
if uid := strings.TrimSpace(c.Query("user_id")); uid != "" { q = q.Where("user_id = ?", uid) }
|
||||
if r := strings.TrimSpace(c.Query("reason")); r != "" { q = q.Where("reason = ?", r) }
|
||||
limit := 100
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 { limit = n }
|
||||
}
|
||||
var items []models.PointsTransaction
|
||||
if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load transactions"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
|
||||
return &EngagementController{DB: db, Email: es}
|
||||
}
|
||||
|
||||
// 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{
|
||||
var body struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Delta int64 `json:"delta"`
|
||||
Reason string `json:"reason"`
|
||||
Meta map[string]interface{} `json:"meta"`
|
||||
CurrentPassword string `json:"current_password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 || body.Delta == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
|
||||
}
|
||||
// Require admin password confirmation for any manual adjustment
|
||||
cu, ok := c.Get("user")
|
||||
if !ok || cu == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error":"not authenticated"}); return
|
||||
}
|
||||
if strings.TrimSpace(body.CurrentPassword) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error":"current_password is required"}); return
|
||||
}
|
||||
currentUser := cu.(*models.User)
|
||||
if err := utils.CheckPassword(body.CurrentPassword, currentUser.Password); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error":"invalid current password"}); return
|
||||
}
|
||||
reason := strings.TrimSpace(body.Reason)
|
||||
if reason == "" { reason = "admin_adjust" }
|
||||
svc := services.NewEngagementService(ec.DB)
|
||||
@@ -175,141 +61,6 @@ func (ec *EngagementController) AdminAdjustPoints(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/v1/engagement/leaderboard (auth)
|
||||
// Query: metric=points|level|xp, limit (default 20, max 100)
|
||||
func (ec *EngagementController) GetLeaderboard(c *gin.Context) {
|
||||
metric := strings.ToLower(strings.TrimSpace(c.Query("metric")))
|
||||
if metric == "" {
|
||||
metric = "points"
|
||||
}
|
||||
limit := 20
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
if n > 0 && n <= 100 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type row struct {
|
||||
UserID uint
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
Role string
|
||||
Points int64
|
||||
Level int
|
||||
XP int64
|
||||
AvatarURL string
|
||||
AnimatedAvatarURL string
|
||||
}
|
||||
q := ec.DB.Table("user_profiles AS up").
|
||||
Select("up.user_id, u.first_name, u.last_name, up.username, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url").
|
||||
Joins("JOIN users u ON u.id = up.user_id")
|
||||
switch metric {
|
||||
case "xp":
|
||||
q = q.Order("up.xp DESC, up.points DESC, up.level DESC")
|
||||
case "level":
|
||||
q = q.Order("up.level DESC, up.xp DESC, up.points DESC")
|
||||
default:
|
||||
q = q.Order("up.points DESC, up.level DESC, up.xp DESC")
|
||||
}
|
||||
q = q.Limit(limit)
|
||||
|
||||
var rows []row
|
||||
if err := q.Scan(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
items = append(items, gin.H{
|
||||
"rank": i + 1,
|
||||
"user_id": r.UserID,
|
||||
"first_name": r.FirstName,
|
||||
"last_name": r.LastName,
|
||||
"username": r.Username,
|
||||
"role": r.Role,
|
||||
"points": r.Points,
|
||||
"level": r.Level,
|
||||
"xp": r.XP,
|
||||
"avatar_url": r.AvatarURL,
|
||||
"animated_avatar_url": r.AnimatedAvatarURL,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/engagement/leaderboard (admin)
|
||||
// Query: metric=points|level|xp, limit (default 50, max 1000)
|
||||
func (ec *EngagementController) AdminGetLeaderboard(c *gin.Context) {
|
||||
metric := strings.ToLower(strings.TrimSpace(c.Query("metric")))
|
||||
if metric == "" {
|
||||
metric = "points"
|
||||
}
|
||||
limit := 50
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
if n > 0 && n <= 1000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type row struct {
|
||||
UserID uint
|
||||
FirstName string
|
||||
LastName string
|
||||
Email string
|
||||
Role string
|
||||
Points int64
|
||||
Level int
|
||||
XP int64
|
||||
AvatarURL string
|
||||
AnimatedAvatarURL string
|
||||
}
|
||||
q := ec.DB.Table("user_profiles AS up").
|
||||
Select("up.user_id, u.first_name, u.last_name, u.email, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url").
|
||||
Joins("JOIN users u ON u.id = up.user_id")
|
||||
switch metric {
|
||||
case "xp":
|
||||
q = q.Order("up.xp DESC, up.points DESC, up.level DESC")
|
||||
case "level":
|
||||
q = q.Order("up.level DESC, up.xp DESC, up.points DESC")
|
||||
default:
|
||||
q = q.Order("up.points DESC, up.level DESC, up.xp DESC")
|
||||
}
|
||||
q = q.Limit(limit)
|
||||
|
||||
var rows []row
|
||||
if err := q.Scan(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
items = append(items, gin.H{
|
||||
"rank": i + 1,
|
||||
"user_id": r.UserID,
|
||||
"first_name": r.FirstName,
|
||||
"last_name": r.LastName,
|
||||
"email": r.Email,
|
||||
"role": r.Role,
|
||||
"points": r.Points,
|
||||
"level": r.Level,
|
||||
"xp": r.XP,
|
||||
"avatar_url": r.AvatarURL,
|
||||
"animated_avatar_url": r.AnimatedAvatarURL,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
|
||||
return &EngagementController{DB: db, Email: es}
|
||||
}
|
||||
|
||||
// GET /api/v1/engagement/profile (auth)
|
||||
func (ec *EngagementController) GetProfile(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
@@ -333,6 +84,7 @@ func (ec *EngagementController) GetProfile(c *gin.Context) {
|
||||
"avatar_upload_unlocked": up.AvatarUploadUnlocked,
|
||||
"animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked,
|
||||
"achievements": achCount,
|
||||
"engagement_disabled": c.GetString("userRole") == "admin",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,45 +135,54 @@ func (ec *EngagementController) PatchProfile(c *gin.Context) {
|
||||
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"`
|
||||
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
|
||||
}
|
||||
if body.AvatarURL == nil && body.AnimatedAvatarURL == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure profile exists and load unlock flags
|
||||
svc := services.NewEngagementService(ec.DB)
|
||||
up, _ := svc.EnsureProfile(userID)
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if body.AvatarURL != nil {
|
||||
url := strings.TrimSpace(*body.AvatarURL)
|
||||
if strings.HasPrefix(url, "/uploads/") {
|
||||
var up models.UserProfile
|
||||
if err := ec.DB.Where("user_id = ?", userID).First(&up).Error; err == nil {
|
||||
if !up.AvatarUploadUnlocked {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Nahrání vlastního avataru je uzamčeno. Odemkněte v obchodě."})
|
||||
return
|
||||
}
|
||||
if url == "" {
|
||||
updates["avatar_url"] = ""
|
||||
} else {
|
||||
// Custom uploads require unlock
|
||||
if strings.HasPrefix(url, "/uploads") && !up.AvatarUploadUnlocked {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Není odemčeno nahrávání vlastního avataru"})
|
||||
return
|
||||
}
|
||||
updates["avatar_url"] = url
|
||||
}
|
||||
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
|
||||
}
|
||||
if url == "" {
|
||||
updates["animated_avatar_url"] = ""
|
||||
} else {
|
||||
if strings.HasPrefix(url, "/uploads") && !up.AnimatedAvatarUploadUnlocked {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Není odemčeno nahrávání animovaného avataru"})
|
||||
return
|
||||
}
|
||||
updates["animated_avatar_url"] = url
|
||||
}
|
||||
updates["animated_avatar_url"] = url
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"})
|
||||
return
|
||||
@@ -438,12 +199,12 @@ 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,
|
||||
Name: "Odemknout vlastní avatar (upload)",
|
||||
Type: "avatar_upload_unlock",
|
||||
CostPoints: 250,
|
||||
ImageURL: "",
|
||||
Stock: -1,
|
||||
Active: true,
|
||||
}
|
||||
_ = ec.DB.Create(&unlock).Error
|
||||
} else {
|
||||
@@ -451,25 +212,6 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
|
||||
_ = 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 {
|
||||
@@ -483,6 +225,11 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
|
||||
func (ec *EngagementController) Redeem(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
// Admins cannot redeem rewards
|
||||
if c.GetString("userRole") == "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admins cannot redeem rewards"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
RewardID uint `json:"reward_id"`
|
||||
}
|
||||
@@ -719,6 +466,18 @@ func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
|
||||
// Load existing to enforce invariants on mandatory reward
|
||||
var existing models.RewardItem
|
||||
_ = ec.DB.First(&existing, id).Error
|
||||
if strings.EqualFold(existing.Type, "avatar_upload_unlock") {
|
||||
// Disallow disabling or changing type of the mandatory reward
|
||||
if body.Active != nil && *body.Active == false {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deactivated"}); return
|
||||
}
|
||||
if body.Type != nil && strings.ToLower(strings.TrimSpace(*body.Type)) != existing.Type {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type cannot be changed for this reward"}); return
|
||||
}
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if body.Name != nil { updates["name"] = strings.TrimSpace(*body.Name) }
|
||||
if body.Type != nil { updates["type"] = strings.TrimSpace(*body.Type) }
|
||||
@@ -738,6 +497,13 @@ func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
|
||||
// DELETE /api/v1/admin/engagement/rewards/:id
|
||||
func (ec *EngagementController) AdminDeleteReward(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// Disallow deleting the mandatory reward
|
||||
var existing models.RewardItem
|
||||
if err := ec.DB.First(&existing, id).Error; err == nil {
|
||||
if strings.EqualFold(existing.Type, "avatar_upload_unlock") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deleted"}); return
|
||||
}
|
||||
}
|
||||
if err := ec.DB.Delete(&models.RewardItem{}, "id = ?", id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to delete reward"}); return
|
||||
}
|
||||
@@ -828,3 +594,283 @@ func (ec *EngagementController) AdminUpdateRedemptionStatus(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "status": newStatus})
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Admins: engagement hidden – return empty list
|
||||
if c.GetString("userRole") == "admin" {
|
||||
c.JSON(http.StatusOK, gin.H{"items": []models.PointsTransaction{}})
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// POST /api/v1/engagement/checkin (auth)
|
||||
// Awards daily check-in points (cap 1/day via service caps); Admins do not earn points
|
||||
func (ec *EngagementController) Checkin(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
// Admins: engagement disabled (no-op)
|
||||
if c.GetString("userRole") == "admin" {
|
||||
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
|
||||
}
|
||||
// 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)
|
||||
// Awards small points for unique article reads (cap 3/day + dedupe per article); Admins do not earn points
|
||||
func (ec *EngagementController) ArticleRead(c *gin.Context) {
|
||||
uid, _ := c.Get("userID")
|
||||
userID := uid.(uint)
|
||||
// Admins: engagement disabled (no-op)
|
||||
if c.GetString("userRole") == "admin" {
|
||||
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
|
||||
}
|
||||
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) {
|
||||
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/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})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
@@ -483,55 +483,90 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
|
||||
}
|
||||
|
||||
// Default admin panel navigation items
|
||||
adminItems := []models.NavigationItem{
|
||||
// Main section
|
||||
{Label: "Nástěnka", Type: models.NavTypeInternal, PageType: "dashboard", DisplayOrder: 0, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Analytika", Type: models.NavTypeInternal, PageType: "analytics", DisplayOrder: 1, Visible: true, RequiresAdmin: true},
|
||||
|
||||
// Content section
|
||||
{Label: "Týmy", Type: models.NavTypeInternal, PageType: "teams", DisplayOrder: 2, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Zápasy", Type: models.NavTypeInternal, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Aktivity", Type: models.NavTypeInternal, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Hráči", Type: models.NavTypeInternal, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Články", Type: models.NavTypeInternal, PageType: "articles", DisplayOrder: 6, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Kategorie", Type: models.NavTypeInternal, PageType: "categories", DisplayOrder: 7, Visible: true, RequiresAdmin: true},
|
||||
{Label: "O klubu", Type: models.NavTypeInternal, PageType: "about", DisplayOrder: 8, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Videa", Type: models.NavTypeInternal, PageType: "videos", DisplayOrder: 9, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Galerie (Zonerama)", Type: models.NavTypeInternal, PageType: "gallery", DisplayOrder: 10, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Tabule (Scoreboard)", Type: models.NavTypeInternal, PageType: "scoreboard", DisplayOrder: 11, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Scoreboard Remote", Type: models.NavTypeInternal, PageType: "scoreboard_remote", DisplayOrder: 12, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Oblečení", Type: models.NavTypeInternal, PageType: "clothing", DisplayOrder: 13, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Sponzoři", Type: models.NavTypeInternal, PageType: "sponsors", DisplayOrder: 14, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Bannery", Type: models.NavTypeInternal, PageType: "banners", DisplayOrder: 15, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Zprávy", Type: models.NavTypeInternal, PageType: "messages", DisplayOrder: 16, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Kontakty", Type: models.NavTypeInternal, PageType: "contacts", DisplayOrder: 17, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Zpravodaj", Type: models.NavTypeInternal, PageType: "newsletter", DisplayOrder: 18, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Ankety", Type: models.NavTypeInternal, PageType: "polls", DisplayOrder: 19, Visible: true, RequiresAdmin: true},
|
||||
|
||||
// Settings section
|
||||
{Label: "Navigace", Type: models.NavTypeInternal, PageType: "navigation", DisplayOrder: 20, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Alias soutěží", Type: models.NavTypeInternal, PageType: "competition_aliases", DisplayOrder: 21, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Prefetch & Cache", Type: models.NavTypeInternal, PageType: "prefetch", DisplayOrder: 22, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Uživatelé", Type: models.NavTypeInternal, PageType: "users", DisplayOrder: 23, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Nastavení", Type: models.NavTypeInternal, PageType: "settings", DisplayOrder: 24, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Zkrácené odkazy", Type: models.NavTypeInternal, PageType: "shortlinks", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
|
||||
|
||||
// Help section
|
||||
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 27, Visible: true, RequiresAdmin: true},
|
||||
}
|
||||
|
||||
// Combine all items
|
||||
allItems := append(frontendItems, adminItems...)
|
||||
|
||||
// Create items in a transaction
|
||||
// Create items in a transaction with admin categories and children
|
||||
err := nc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range allItems {
|
||||
for _, item := range frontendItems {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
catOrder := 0
|
||||
createCategory := func(label string) (*models.NavigationItem, error) {
|
||||
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
|
||||
catOrder++
|
||||
if err := tx.Create(cat).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
|
||||
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
|
||||
pid := parent.ID
|
||||
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
|
||||
child.ParentID = &pid
|
||||
return tx.Create(child).Error
|
||||
}
|
||||
|
||||
zakladni, err := createCategory("Základní")
|
||||
if err != nil { return err }
|
||||
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
|
||||
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
|
||||
|
||||
sport, err := createCategory("Sport")
|
||||
if err != nil { return err }
|
||||
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
|
||||
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
|
||||
if err := createChild(sport, "Aktivity", "activities", 2); err != nil { return err }
|
||||
if err := createChild(sport, "Hráči", "players", 3); err != nil { return err }
|
||||
|
||||
obsah, err := createCategory("Obsah")
|
||||
if err != nil { return err }
|
||||
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
|
||||
if err := createChild(obsah, "Kategorie", "categories", 1); err != nil { return err }
|
||||
if err := createChild(obsah, "O klubu", "about", 2); err != nil { return err }
|
||||
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
|
||||
|
||||
media, err := createCategory("Média")
|
||||
if err != nil { return err }
|
||||
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
|
||||
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
|
||||
if err := createChild(media, "Média", "media", 2); err != nil { return err }
|
||||
if err := createChild(media, "Soubory", "files", 3); err != nil { return err }
|
||||
|
||||
kom, err := createCategory("Komunikace")
|
||||
if err != nil { return err }
|
||||
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
|
||||
if err := createChild(kom, "Kontakty", "contacts", 1); err != nil { return err }
|
||||
if err := createChild(kom, "Zpravodaj", "newsletter", 2); err != nil { return err }
|
||||
|
||||
marketing, err := createCategory("Marketing")
|
||||
if err != nil { return err }
|
||||
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
|
||||
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
|
||||
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
|
||||
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 3); err != nil { return err }
|
||||
if err := createChild(marketing, "Ankety", "polls", 4); err != nil { return err }
|
||||
if err := createChild(marketing, "Soutěže", "sweepstakes", 5); err != nil { return err }
|
||||
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 6); err != nil { return err }
|
||||
|
||||
nastroje, err := createCategory("Nástroje")
|
||||
if err != nil { return err }
|
||||
if err := createChild(nastroje, "Tabule (Scoreboard)", "scoreboard", 0); err != nil { return err }
|
||||
if err := createChild(nastroje, "Scoreboard Remote", "scoreboard_remote", 1); err != nil { return err }
|
||||
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 2); err != nil { return err }
|
||||
|
||||
nastaveni, err := createCategory("Nastavení")
|
||||
if err != nil { return err }
|
||||
if err := createChild(nastaveni, "Navigace", "navigation", 0); err != nil { return err }
|
||||
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
|
||||
if err := createChild(nastaveni, "Nastavení", "settings", 2); err != nil { return err }
|
||||
if err := createChild(nastaveni, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
|
||||
|
||||
napoveda, err := createCategory("Nápověda")
|
||||
if err != nil { return err }
|
||||
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -540,11 +575,19 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Since creation is split, compute counts again
|
||||
var total int64
|
||||
nc.DB.Model(&models.NavigationItem{}).Count(&total)
|
||||
var frontendCount int64
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
||||
var adminCount int64
|
||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Default navigation items created successfully",
|
||||
"count": len(allItems),
|
||||
"frontend_count": len(frontendItems),
|
||||
"admin_count": len(adminItems),
|
||||
"count": total,
|
||||
"frontend_count": frontendCount,
|
||||
"admin_count": adminCount,
|
||||
"seeded": true,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user