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