Files
MyClub/internal/controllers/analytics_controller.go
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

436 lines
13 KiB
Go

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})
}