package services import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "time" "fotbal-club/internal/config" "fotbal-club/pkg/logger" ) type UmamiService struct { baseURL string username string password string token string tokenExp time.Time lastVerified time.Time defaultWebsiteID string } // --- Helpers for website discovery/creation and event sending --- // UmamiWebsite represents a single website entry as returned by the list API type UmamiWebsite struct { ID string `json:"id"` Name string `json:"name"` Domain string `json:"domain"` } // umamiListWebsitesResponse matches the shape of GET /api/websites type umamiListWebsitesResponse struct { Data []UmamiWebsite `json:"data"` Count int `json:"count"` Page int `json:"page"` PageSize int `json:"pageSize"` } var ErrUmamiNoWebsites = errors.New("no websites found in Umami") // FindWebsiteIDByDomain returns the website ID for a given domain if it exists. func (u *UmamiService) FindWebsiteIDByDomain(domain string) (string, error) { if err := u.authenticate(); err != nil { return "", err } url := fmt.Sprintf("%s/api/websites?page=1&pageSize=100&orderBy=name&query=%s", u.baseURL, domain) req, err := http.NewRequest("GET", url, nil) if err != nil { return "", fmt.Errorf("failed to create list websites request: %w", err) } req.Header.Set("Authorization", "Bearer "+u.token) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to send list websites request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("list websites failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var list umamiListWebsitesResponse if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { return "", fmt.Errorf("failed to decode list websites response: %w", err) } for _, w := range list.Data { if w.Domain == domain { return w.ID, nil } } return "", nil } // GetDefaultWebsiteID returns the first available website ID from Umami func (u *UmamiService) GetDefaultWebsiteID() (string, error) { logger.Info("Attempting to get default Umami website ID from %s", u.baseURL) if err := u.authenticate(); err != nil { return "", fmt.Errorf("authentication failed: %w", err) } url := fmt.Sprintf("%s/api/websites?page=1&pageSize=1&orderBy=name", u.baseURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return "", fmt.Errorf("failed to create list websites request: %w", err) } req.Header.Set("Authorization", "Bearer "+u.token) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to send list websites request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("list websites failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var list umamiListWebsitesResponse if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { return "", fmt.Errorf("failed to decode list websites response: %w", err) } if len(list.Data) == 0 { logger.Warn("No websites found in Umami instance at %s. Please create a website first.", u.baseURL) return "", ErrUmamiNoWebsites } u.defaultWebsiteID = list.Data[0].ID logger.Info("Found default Umami website: ID=%s, Name=%s, Domain=%s", list.Data[0].ID, list.Data[0].Name, list.Data[0].Domain) return list.Data[0].ID, nil } // EnsureWebsite returns an existing website ID for the domain or creates a new one. func (u *UmamiService) EnsureWebsite(name, domain string) (string, error) { if domain == "" { return "", fmt.Errorf("domain is required") } if err := u.authenticate(); err != nil { return "", err } if id, err := u.FindWebsiteIDByDomain(domain); err == nil && id != "" { return id, nil } else if err != nil { return "", err } return u.CreateWebsite(name, domain) } // SendEvent posts a custom event to Umami's public /api/send endpoint (no auth required). // hostname is a label for the source (e.g., "email" or your site host). func (u *UmamiService) SendEvent(websiteID, name, urlPath, title string, data map[string]interface{}, hostname string) error { if u.baseURL == "" || websiteID == "" { return fmt.Errorf("umami baseURL and websiteID are required") } payload := map[string]interface{}{ "payload": map[string]interface{}{ "hostname": hostname, "language": "", "referrer": "", "screen": "", "title": title, "url": urlPath, "website": websiteID, "name": name, "data": data, }, "type": "event", } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal send event payload: %w", err) } req, err := http.NewRequest("POST", u.baseURL+"/api/send", bytes.NewBuffer(body)) if err != nil { return fmt.Errorf("failed to create send event request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "fotbal-club/server") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send umami event: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("umami send event status %d: %s", resp.StatusCode, string(b)) } return nil } type UmamiAuthRequest struct { Username string `json:"username"` Password string `json:"password"` } type UmamiAuthResponse struct { Token string `json:"token"` } type UmamiCreateWebsiteRequest struct { Name string `json:"name"` Domain string `json:"domain"` } type UmamiWebsiteResponse struct { ID string `json:"id"` Name string `json:"name"` Domain string `json:"domain"` CreatedAt time.Time `json:"createdAt"` } // NewUmamiService creates a new Umami service instance func NewUmamiService() *UmamiService { return &UmamiService{ baseURL: config.AppConfig.UmamiURL, username: config.AppConfig.UmamiUsername, password: config.AppConfig.UmamiPassword, } } // verifyToken checks if the current token is still valid using /api/auth/verify func (u *UmamiService) verifyToken() bool { if u.token == "" { return false } req, err := http.NewRequest("POST", u.baseURL+"/api/auth/verify", nil) if err != nil { return false } req.Header.Set("Authorization", "Bearer "+u.token) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return false } defer resp.Body.Close() // Token is valid if we get 200 OK if resp.StatusCode == http.StatusOK { u.lastVerified = time.Now() logger.Info("Umami token verified successfully") return true } logger.Info("Umami token verification failed with status %d", resp.StatusCode) return false } // authenticate gets a JWT token from Umami func (u *UmamiService) authenticate() error { if u.baseURL == "" || u.username == "" || u.password == "" { return fmt.Errorf("umami credentials not configured") } // Check if we have a token and it hasn't expired if u.token != "" && time.Now().Before(u.tokenExp) { // Verify token twice daily (every 12 hours) if time.Since(u.lastVerified) < 12*time.Hour { return nil } // Time to verify - check if token is still valid if u.verifyToken() { return nil } // Token invalid, will re-authenticate below logger.Info("Token expired or invalid, re-authenticating...") } authReq := UmamiAuthRequest{ Username: u.username, Password: u.password, } body, err := json.Marshal(authReq) if err != nil { return fmt.Errorf("failed to marshal auth request: %w", err) } req, err := http.NewRequest("POST", u.baseURL+"/api/auth/login", bytes.NewBuffer(body)) if err != nil { return fmt.Errorf("failed to create auth request: %w", err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send auth request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("authentication failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var authResp UmamiAuthResponse if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { return fmt.Errorf("failed to decode auth response: %w", err) } u.token = authResp.Token // Set token expiration to 23 hours from now (tokens typically last 24h) u.tokenExp = time.Now().Add(23 * time.Hour) u.lastVerified = time.Now() logger.Info("Successfully authenticated with Umami at %s (new token issued, expires at %s)", u.baseURL, u.tokenExp.Format("2006-01-02 15:04:05")) return nil } // CreateWebsite creates a new website in Umami and returns the website ID func (u *UmamiService) CreateWebsite(name, domain string) (string, error) { if err := u.authenticate(); err != nil { return "", err } createReq := UmamiCreateWebsiteRequest{ Name: name, Domain: domain, } body, err := json.Marshal(createReq) if err != nil { return "", fmt.Errorf("failed to marshal create website request: %w", err) } req, err := http.NewRequest("POST", u.baseURL+"/api/websites", bytes.NewBuffer(body)) if err != nil { return "", fmt.Errorf("failed to create website request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+u.token) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to send create website request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { bodyBytes, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("create website failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var websiteResp UmamiWebsiteResponse if err := json.NewDecoder(resp.Body).Decode(&websiteResp); err != nil { return "", fmt.Errorf("failed to decode website response: %w", err) } logger.Info("Successfully created Umami website: %s (ID: %s)", name, websiteResp.ID) return websiteResp.ID, nil } // GetWebsiteStats retrieves analytics stats for a website func (u *UmamiService) GetWebsiteStats(websiteID string, startAt, endAt int64) (map[string]interface{}, error) { if err := u.authenticate(); err != nil { return nil, err } url := fmt.Sprintf("%s/api/websites/%s/stats?startAt=%d&endAt=%d", u.baseURL, websiteID, startAt, endAt) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create stats request: %w", err) } req.Header.Set("Authorization", "Bearer "+u.token) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to send stats request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get stats failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var stats map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { return nil, fmt.Errorf("failed to decode stats response: %w", err) } return stats, nil } // GetWebsiteMetrics retrieves metrics for a website func (u *UmamiService) GetWebsiteMetrics(websiteID, type_ string, startAt, endAt int64) ([]map[string]interface{}, error) { if err := u.authenticate(); err != nil { return nil, err } url := fmt.Sprintf("%s/api/websites/%s/metrics?type=%s&startAt=%d&endAt=%d", u.baseURL, websiteID, type_, startAt, endAt) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create metrics request: %w", err) } req.Header.Set("Authorization", "Bearer "+u.token) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to send metrics request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get metrics failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var metrics []map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&metrics); err != nil { return nil, fmt.Errorf("failed to decode metrics response: %w", err) } return metrics, nil } // GetWebsitePageviews retrieves pageviews data over time with specified unit (hour, day, month, year) func (u *UmamiService) GetWebsitePageviews(websiteID string, startAt, endAt int64, unit string) ([]map[string]interface{}, error) { if err := u.authenticate(); err != nil { return nil, err } url := fmt.Sprintf("%s/api/websites/%s/pageviews?startAt=%d&endAt=%d&unit=%s", u.baseURL, websiteID, startAt, endAt, unit) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create pageviews request: %w", err) } req.Header.Set("Authorization", "Bearer "+u.token) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to send pageviews request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get pageviews failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var pageviews []map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&pageviews); err != nil { return nil, fmt.Errorf("failed to decode pageviews response: %w", err) } return pageviews, nil }