mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
436 lines
13 KiB
Go
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})
|
|
}
|