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

365 lines
11 KiB
Go

package controllers
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
)
type UmamiController struct {
umamiService *services.UmamiService
}
func NewUmamiController() *UmamiController {
return &UmamiController{
umamiService: services.NewUmamiService(),
}
}
// resolveWebsiteID attempts to obtain a usable Umami website ID for requests.
// Preference order:
// 1. Cached value in configuration.
// 2. Match by configured frontend domain.
// 3. Fall back to the first website available in Umami.
func (uc *UmamiController) resolveWebsiteID() (string, error) {
if id := strings.TrimSpace(config.AppConfig.UmamiWebsiteID); id != "" {
logger.Info("Using cached Umami website ID: %s", id)
return id, nil
}
logger.Info("UMAMI_WEBSITE_ID is empty, attempting to auto-detect...")
if frontendURL := strings.TrimSpace(config.AppConfig.FrontendBaseURL); frontendURL != "" {
parsed, err := url.Parse(frontendURL)
if err != nil {
logger.Warn("Failed to parse FRONTEND_BASE_URL '%s': %v", frontendURL, err)
} else if host := parsed.Hostname(); host != "" {
logger.Info("Attempting to find Umami website by domain: %s", host)
id, err := uc.umamiService.FindWebsiteIDByDomain(host)
if err != nil {
logger.Warn("Failed to find website by domain '%s': %v", host, err)
} else if id != "" {
logger.Info("Found Umami website by domain match: %s", id)
config.AppConfig.UmamiWebsiteID = id
return id, nil
} else {
logger.Info("No Umami website found with domain '%s'", host)
}
}
}
logger.Info("Falling back to first available Umami website...")
id, err := uc.umamiService.GetDefaultWebsiteID()
if err != nil {
return "", err
}
config.AppConfig.UmamiWebsiteID = id
logger.Info("Auto-detected and cached Umami website ID: %s", id)
return id, nil
}
// GetUmamiConfig returns the Umami configuration for the frontend
func (uc *UmamiController) GetUmamiConfig(c *gin.Context) {
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
reason := "No website ID configured - run initial setup"
switch {
case config.AppConfig.UmamiURL == "":
reason = "UMAMI_URL not set in .env"
case config.AppConfig.UmamiUsername == "":
reason = "UMAMI_USERNAME not set in .env"
case config.AppConfig.UmamiPassword == "":
reason = "UMAMI_PASSWORD not set in .env"
}
logger.Warn("Umami not configured: %s", reason)
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"website_id": "",
"script_url": "",
"reason": reason,
})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"website_id": "",
"script_url": "",
"reason": "No websites found in Umami",
})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve Umami website ID"})
}
return
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"website_id": websiteID,
"script_url": config.AppConfig.UmamiURL + "/script.js",
})
}
// InitializeUmamiSetup is called during initial setup (no auth required).
// It auto-detects the domain from the Host header (works with Cloudflare Tunnel).
func (uc *UmamiController) InitializeUmamiSetup(c *gin.Context) {
if config.AppConfig.UmamiWebsiteID != "" {
c.JSON(http.StatusOK, gin.H{
"message": "Umami already configured",
"website_id": config.AppConfig.UmamiWebsiteID,
})
return
}
var payload struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
if payload.Name == "" {
payload.Name = "Fotbal Club"
}
domain := c.Request.Host
if i := strings.Index(domain, ":"); i >= 0 {
domain = domain[:i]
}
logger.Info("Initializing Umami website: name='%s', domain='%s'", payload.Name, domain)
websiteID, err := uc.umamiService.EnsureWebsite(payload.Name, domain)
if err != nil {
logger.Error("Failed to create Umami website: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Umami website: " + err.Error()})
return
}
config.AppConfig.UmamiWebsiteID = websiteID
logger.Info("Umami website created successfully: ID=%s", websiteID)
c.JSON(http.StatusOK, gin.H{
"message": "Umami initialized successfully",
"website_id": websiteID,
"script_url": config.AppConfig.UmamiURL + "/script.js",
"domain": domain,
})
}
// InitializeUmami sets up Umami tracking (admin endpoint with manual domain).
func (uc *UmamiController) InitializeUmami(c *gin.Context) {
if config.AppConfig.UmamiWebsiteID != "" {
c.JSON(http.StatusOK, gin.H{
"message": "Umami already configured",
"website_id": config.AppConfig.UmamiWebsiteID,
})
return
}
var payload struct {
Name string `json:"name"`
Domain string `json:"domain"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
if payload.Name == "" || payload.Domain == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Name and domain are required"})
return
}
websiteID, err := uc.umamiService.EnsureWebsite(payload.Name, payload.Domain)
if err != nil {
logger.Error("Failed to create Umami website: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Umami website: " + err.Error()})
return
}
config.AppConfig.UmamiWebsiteID = websiteID
c.JSON(http.StatusOK, gin.H{
"message": "Umami initialized successfully",
"website_id": websiteID,
"script_url": config.AppConfig.UmamiURL + "/script.js",
})
}
// GetStats returns analytics stats from Umami
func (uc *UmamiController) GetStats(c *gin.Context) {
// Check if Umami is configured
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
logger.Warn("Umami not configured - UMAMI_URL='%s', username empty=%v, password empty=%v",
config.AppConfig.UmamiURL,
config.AppConfig.UmamiUsername == "",
config.AppConfig.UmamiPassword == "")
c.JSON(http.StatusOK, gin.H{})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
logger.Warn("No Umami websites found in instance at %s - returning empty stats", config.AppConfig.UmamiURL)
c.JSON(http.StatusOK, gin.H{})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusOK, gin.H{})
}
return
}
logger.Info("Using Umami website ID: %s", websiteID)
// Get time range from query params (default to last 30 days)
days := 30
if d := c.Query("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
days = parsed
}
}
endAt := time.Now().Unix() * 1000 // milliseconds
startAt := time.Now().AddDate(0, 0, -days).Unix() * 1000
stats, err := uc.umamiService.GetWebsiteStats(websiteID, startAt, endAt)
if err != nil {
logger.Error("Failed to get Umami stats for websiteID=%s (days=%d): %v", websiteID, days, err)
// Return empty stats instead of error
c.JSON(http.StatusOK, gin.H{})
return
}
logger.Info("Successfully fetched Umami stats for websiteID=%s (days=%d)", websiteID, days)
c.JSON(http.StatusOK, stats)
}
// GetMetrics returns specific metrics from Umami (pageviews, referrers, browsers, os, devices, countries, events)
func (uc *UmamiController) GetMetrics(c *gin.Context) {
// Check if Umami is configured
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
logger.Warn("Umami not configured - returning empty metrics")
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
logger.Warn("No Umami websites found - returning empty metrics")
c.JSON(http.StatusOK, []map[string]interface{}{})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusOK, []map[string]interface{}{})
}
return
}
metricType := c.Param("type")
if metricType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Metric type is required"})
return
}
days := 30
if d := c.Query("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
days = parsed
}
}
endAt := time.Now().Unix() * 1000
startAt := time.Now().AddDate(0, 0, -days).Unix() * 1000
metrics, err := uc.umamiService.GetWebsiteMetrics(websiteID, metricType, startAt, endAt)
if err != nil {
logger.Error("Failed to get Umami metrics (websiteID=%s, type=%s, days=%d): %v", websiteID, metricType, days, err)
// Return empty metrics instead of error
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
logger.Info("Successfully fetched %d Umami metrics (websiteID=%s, type=%s, days=%d)", len(metrics), websiteID, metricType, days)
c.JSON(http.StatusOK, metrics)
}
// GetPageviews returns pageviews data over time from Umami
func (uc *UmamiController) GetPageviews(c *gin.Context) {
// Check if Umami is configured
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
logger.Warn("Umami not configured - returning empty pageviews")
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
logger.Warn("No Umami websites found - returning empty pageviews")
c.JSON(http.StatusOK, []map[string]interface{}{})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusOK, []map[string]interface{}{})
}
return
}
days := 30
if d := c.Query("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed >= 0 {
days = parsed
}
}
// Determine time unit based on range
unit := "day"
if days == 0 || days == 1 {
unit = "hour"
} else if days <= 90 {
unit = "day"
} else {
unit = "month"
}
// Calculate time range
var startAt, endAt int64
endDate := time.Now()
if days == 0 {
// Today: from midnight to now
startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, endDate.Location())
startAt = startDate.Unix() * 1000
endAt = endDate.Unix() * 1000
} else {
// Other ranges: from X days ago to now
startDate := endDate.AddDate(0, 0, -days)
startAt = startDate.Unix() * 1000
endAt = endDate.Unix() * 1000
}
logger.Info("Fetching pageviews: websiteID=%s, days=%d, unit=%s, startAt=%d, endAt=%d", websiteID, days, unit, startAt, endAt)
pageviews, err := uc.umamiService.GetWebsitePageviews(websiteID, startAt, endAt, unit)
if err != nil {
logger.Error("Failed to get Umami pageviews (websiteID=%s, days=%d, unit=%s): %v", websiteID, days, unit, err)
// Return empty array instead of error to prevent frontend crash
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
c.JSON(http.StatusOK, pageviews)
}