package controllers import ( "crypto/sha256" "encoding/hex" "fmt" "net/http" "strconv" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/internal/services" "fotbal-club/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type AnalyticsController struct { DB *gorm.DB umamiService *services.UmamiService } func NewAnalyticsController(db *gorm.DB) *AnalyticsController { return &AnalyticsController{ DB: db, umamiService: services.NewUmamiService(), } } // resolveWebsiteID attempts to obtain a usable Umami website ID for requests func (ac *AnalyticsController) resolveWebsiteID() (string, error) { if id := strings.TrimSpace(config.AppConfig.UmamiWebsiteID); id != "" { return id, nil } // Try to get the first available website id, err := ac.umamiService.GetDefaultWebsiteID() if err != nil { return "", err } config.AppConfig.UmamiWebsiteID = id return id, nil } // getClientIP extracts the real client IP address func getClientIP(c *gin.Context) string { // Check X-Forwarded-For header xff := c.GetHeader("X-Forwarded-For") if xff != "" { ips := strings.Split(xff, ",") return strings.TrimSpace(ips[0]) } // Check X-Real-IP header xri := c.GetHeader("X-Real-IP") if xri != "" { return xri } // Fall back to RemoteAddr return c.ClientIP() } // hashIP creates a privacy-preserving hash of IP address func hashIP(ip string) string { // Add salt to make it harder to reverse salted := ip + "_fotbal_club_2025" hash := sha256.Sum256([]byte(salted)) return hex.EncodeToString(hash[:]) } // Track generic event (page view, click, etc.) func (ac *AnalyticsController) TrackEvent(c *gin.Context) { var payload models.VisitorEvent if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Set client IP from request (hashed for privacy) realIP := getClientIP(c) payload.IPAddress = hashIP(realIP) payload.UserAgent = c.GetHeader("User-Agent") payload.Referrer = c.GetHeader("Referer") // Synchronize Page and PagePath fields if payload.PagePath != "" && payload.Page == "" { payload.Page = payload.PagePath } else if payload.Page != "" && payload.PagePath == "" { payload.PagePath = payload.Page } if err := ac.DB.Create(&payload).Error; err != nil { logger.Error("Failed to track event: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to track event"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // GetAnalytics returns general analytics summary with users, events, and articles stats func (ac *AnalyticsController) GetAnalytics(c *gin.Context) { // Get users stats var totalUsers int64 ac.DB.Model(&models.User{}).Count(&totalUsers) // Get new users this week weekAgo := time.Now().AddDate(0, 0, -7) var newUsersThisWeek int64 ac.DB.Model(&models.User{}).Where("created_at >= ?", weekAgo).Count(&newUsersThisWeek) // Get events stats var totalEvents int64 ac.DB.Model(&models.Event{}).Count(&totalEvents) // Get upcoming events (events with start_time in the future) now := time.Now() var upcomingEvents int64 ac.DB.Model(&models.Event{}).Where("start_time > ?", now).Count(&upcomingEvents) // Get articles stats var totalArticles int64 ac.DB.Model(&models.Article{}).Count(&totalArticles) var publishedArticles int64 ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles) c.JSON(http.StatusOK, gin.H{ "users": gin.H{ "total": totalUsers, "new_this_week": newUsersThisWeek, }, "events": gin.H{ "total": totalEvents, "upcoming": upcomingEvents, }, "articles": gin.H{ "total": totalArticles, "published": publishedArticles, }, }) } // GetVisitors returns visitor statistics grouped by day func (ac *AnalyticsController) GetVisitors(c *gin.Context) { // Get parameters days := 30 if d := c.Query("days"); d != "" { if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 { days = parsed } } groupBy := c.DefaultQuery("groupBy", "day") startDate := time.Now().AddDate(0, 0, -days) type VisitorStat struct { Date string `json:"date"` PageViews int64 `json:"pageViews"` UniqueVisitors int64 `json:"uniqueVisitors"` } var stats []VisitorStat // Group by date if groupBy == "day" { ac.DB.Model(&models.VisitorEvent{}). Select("DATE(created_at) as date, COUNT(*) as page_views, COUNT(DISTINCT ip_address) as unique_visitors"). Where("event_type = ? AND created_at >= ?", "page_view", startDate). Group("DATE(created_at)"). Order("date ASC"). Scan(&stats) } else { // Default to day grouping ac.DB.Model(&models.VisitorEvent{}). Select("DATE(created_at) as date, COUNT(*) as page_views, COUNT(DISTINCT ip_address) as unique_visitors"). Where("event_type = ? AND created_at >= ?", "page_view", startDate). Group("DATE(created_at)"). Order("date ASC"). Scan(&stats) } // Transform data for chart format labels := make([]string, 0, len(stats)) pageViewsData := make([]int64, 0, len(stats)) uniqueVisitorsData := make([]int64, 0, len(stats)) var totalVisitors int64 for _, stat := range stats { // Format date as "d. M." (e.g., "5. 10.") t, err := time.Parse("2006-01-02", stat.Date) if err == nil { labels = append(labels, fmt.Sprintf("%d. %d.", t.Day(), int(t.Month()))) } else { labels = append(labels, stat.Date) } pageViewsData = append(pageViewsData, stat.PageViews) uniqueVisitorsData = append(uniqueVisitorsData, stat.UniqueVisitors) totalVisitors += stat.UniqueVisitors } // Calculate change percentage (compare last 7 days with previous 7 days) var changePercentage float64 if len(stats) >= 14 { var recentSum, previousSum int64 for i := len(stats) - 7; i < len(stats); i++ { recentSum += stats[i].UniqueVisitors } for i := len(stats) - 14; i < len(stats)-7; i++ { previousSum += stats[i].UniqueVisitors } if previousSum > 0 { changePercentage = float64(recentSum-previousSum) / float64(previousSum) * 100 } } response := gin.H{ "totalVisitors": totalVisitors, "changePercentage": changePercentage, "chartData": gin.H{ "labels": labels, "datasets": []gin.H{ { "label": "Návštěvníci", "data": uniqueVisitorsData, "borderColor": "rgba(66, 153, 225, 1)", "backgroundColor": "rgba(66, 153, 225, 0.5)", "tension": 0.3, "fill": true, }, }, }, } c.JSON(http.StatusOK, response) } // GetAnalyticsOverview returns overview statistics for admin dashboard func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) { var totalPageViews, uniqueVisitors, pageViewsToday, pageViewsWeek, uniqueVisitorsWeek int64 // Try to fetch from Umami first websiteID, err := ac.resolveWebsiteID() if err == nil && websiteID != "" { // Fetch overall stats (last 365 days for total) endAt := time.Now().Unix() * 1000 startAt := time.Now().AddDate(-1, 0, 0).Unix() * 1000 stats, err := ac.umamiService.GetWebsiteStats(websiteID, startAt, endAt) if err == nil { if pv, ok := stats["pageviews"].(map[string]interface{}); ok { if val, ok := pv["value"].(float64); ok { totalPageViews = int64(val) } } if v, ok := stats["visitors"].(map[string]interface{}); ok { if val, ok := v["value"].(float64); ok { uniqueVisitors = int64(val) } } } // Fetch today's stats todayStart := time.Now().Truncate(24 * time.Hour).Unix() * 1000 todayEnd := time.Now().Unix() * 1000 todayStats, err := ac.umamiService.GetWebsiteStats(websiteID, todayStart, todayEnd) if err == nil { if pv, ok := todayStats["pageviews"].(map[string]interface{}); ok { if val, ok := pv["value"].(float64); ok { pageViewsToday = int64(val) } } } // Fetch this week's stats weekStart := time.Now().AddDate(0, 0, -7).Unix() * 1000 weekEnd := time.Now().Unix() * 1000 weekStats, err := ac.umamiService.GetWebsiteStats(websiteID, weekStart, weekEnd) if err == nil { if pv, ok := weekStats["pageviews"].(map[string]interface{}); ok { if val, ok := pv["value"].(float64); ok { pageViewsWeek = int64(val) } } if v, ok := weekStats["visitors"].(map[string]interface{}); ok { if val, ok := v["value"].(float64); ok { uniqueVisitorsWeek = int64(val) } } } } else { // Fallback to internal analytics if Umami is not available ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Count(&totalPageViews) ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Distinct("ip_address").Count(&uniqueVisitors) today := time.Now().Format("2006-01-02") ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND DATE(created_at) = ?", "page_view", today).Count(&pageViewsToday) weekAgo := time.Now().AddDate(0, 0, -7) ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Count(&pageViewsWeek) ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Distinct("ip_address").Count(&uniqueVisitorsWeek) } // Total and published articles (always from DB) var totalArticles, publishedArticles int64 ac.DB.Model(&models.Article{}).Count(&totalArticles) ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles) c.JSON(http.StatusOK, gin.H{ "total_page_views": totalPageViews, "unique_visitors": uniqueVisitors, "total_articles": totalArticles, "published_articles": publishedArticles, "page_views_today": pageViewsToday, "page_views_week": pageViewsWeek, "unique_visitors_week": uniqueVisitorsWeek, }) } // GetTopPages returns the most visited pages func (ac *AnalyticsController) GetTopPages(c *gin.Context) { limit := 10 if l := c.Query("limit"); l != "" { fmt.Sscanf(l, "%d", &limit) } type PageStats struct { PagePath string `json:"page_path"` PageName string `json:"page_name"` ViewCount int64 `json:"view_count"` UniqueVisitors int64 `json:"unique_visitors"` } var pages []PageStats // Try to fetch from Umami first websiteID, err := ac.resolveWebsiteID() if err == nil && websiteID != "" { // Fetch URL metrics from Umami (last 30 days) endAt := time.Now().Unix() * 1000 startAt := time.Now().AddDate(0, 0, -30).Unix() * 1000 metrics, err := ac.umamiService.GetWebsiteMetrics(websiteID, "url", startAt, endAt) if err == nil && metrics != nil { // Convert Umami metrics to PageStats format for i, metricMap := range metrics { if i >= limit { break } pagePath := "" viewCount := int64(0) if x, ok := metricMap["x"].(string); ok { pagePath = x } if y, ok := metricMap["y"].(float64); ok { viewCount = int64(y) } pages = append(pages, PageStats{ PagePath: pagePath, PageName: pagePath, ViewCount: viewCount, UniqueVisitors: viewCount, // Umami doesn't separate these }) } c.JSON(http.StatusOK, pages) return } } // Fallback to internal analytics ac.DB.Model(&models.VisitorEvent{}). Where("event_type = ?", "page_view"). Select("page_path, page_name, COUNT(*) as view_count, COUNT(DISTINCT ip_address) as unique_visitors"). Group("page_path, page_name"). Order("view_count DESC"). Limit(limit). Scan(&pages) c.JSON(http.StatusOK, pages) } // GetTopArticles returns the most viewed articles func (ac *AnalyticsController) GetTopArticles(c *gin.Context) { limit := 10 if l := c.Query("limit"); l != "" { fmt.Sscanf(l, "%d", &limit) } var articles []models.Article ac.DB.Where("published = ?", true). Order("view_count DESC"). Limit(limit). Find(&articles) c.JSON(http.StatusOK, articles) } type TopInteraction struct { Page string `json:"page"` Element string `json:"element"` Count int64 `json:"count"` } func (ctrl *AnalyticsController) GetTopInteractions(c *gin.Context) { daysParam := c.DefaultQuery("days", "30") limitParam := c.DefaultQuery("limit", "10") days, _ := strconv.Atoi(daysParam) if days <= 0 || days > 365 { days = 30 } limit, _ := strconv.Atoi(limitParam) if limit <= 0 || limit > 100 { limit = 10 } start := time.Now().AddDate(0, 0, -days) var rows []TopInteraction err := ctrl.DB. Model(&models.VisitorEvent{}). Select("page, element, COUNT(*) as count"). Where("event_type IN ? AND created_at >= ?", []string{"click", "interaction"}, start). Group("page, element"). Order("count DESC"). Limit(limit). Scan(&rows).Error if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load interactions"}) return } c.JSON(http.StatusOK, gin.H{"items": rows}) }