Files
MyClub/internal/services/umami_service.go
T
Tomas Dvorak 77213f4e83 dev day #65
2025-10-19 17:16:57 +02:00

564 lines
17 KiB
Go

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"`
TeamId *string `json:"teamId,omitempty"` // Optional: team ID for Umami v2
}
type UmamiWebsiteResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
TeamId *string `json:"teamId"`
CreatedAt time.Time `json:"createdAt"`
}
// UmamiTeam represents a team in Umami
type UmamiTeam struct {
ID string `json:"id"`
Name string `json:"name"`
}
// umamiTeamsResponse matches the shape of GET /api/users/:userId/teams
type umamiTeamsResponse struct {
Data []UmamiTeam `json:"data"`
Count int `json:"count"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// 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
}
// GetUserTeams retrieves the authenticated user's teams
func (u *UmamiService) GetUserTeams(userID string) ([]UmamiTeam, error) {
if err := u.authenticate(); err != nil {
return nil, err
}
url := fmt.Sprintf("%s/api/users/%s/teams?page=1&pageSize=10", u.baseURL, userID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create teams 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 teams request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get teams failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var teamsResp umamiTeamsResponse
if err := json.NewDecoder(resp.Body).Decode(&teamsResp); err != nil {
return nil, fmt.Errorf("failed to decode teams response: %w", err)
}
return teamsResp.Data, nil
}
// GetCurrentUser retrieves the current authenticated user info from Umami
func (u *UmamiService) GetCurrentUser() (map[string]interface{}, error) {
if err := u.authenticate(); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", u.baseURL+"/api/auth/verify", nil)
if err != nil {
return nil, fmt.Errorf("failed to create verify 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 verify request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("verify failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var user map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to decode user response: %w", err)
}
return user, 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
}
logger.Info("Creating Umami website: name='%s', domain='%s'", name, domain)
// Try to get user info and teams for Umami v2 compatibility
var teamID *string
user, err := u.GetCurrentUser()
if err != nil {
logger.Warn("Failed to get current user info (continuing without team): %v", err)
} else {
if userID, ok := user["id"].(string); ok && userID != "" {
teams, err := u.GetUserTeams(userID)
if err != nil {
logger.Warn("Failed to fetch user teams (continuing without team): %v", err)
} else if len(teams) > 0 {
// Use the first available team
teamID = &teams[0].ID
logger.Info("Using team ID: %s (team name: %s)", teams[0].ID, teams[0].Name)
}
}
}
createReq := UmamiCreateWebsiteRequest{
Name: name,
Domain: domain,
TeamId: teamID,
}
body, err := json.Marshal(createReq)
if err != nil {
return "", fmt.Errorf("failed to marshal create website request: %w", err)
}
logger.Info("Sending website creation request to Umami API: %s/api/websites", u.baseURL)
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()
// Read response body for detailed error logging
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
logger.Error("Umami website creation failed with status %d: %s", resp.StatusCode, string(bodyBytes))
return "", fmt.Errorf("create website failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var websiteResp UmamiWebsiteResponse
if err := json.Unmarshal(bodyBytes, &websiteResp); err != nil {
logger.Error("Failed to decode website response: %v, body: %s", err, string(bodyBytes))
return "", fmt.Errorf("failed to decode website response: %w", err)
}
logger.Info("Successfully created Umami website: %s (ID: %s, Domain: %s)", name, websiteResp.ID, websiteResp.Domain)
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
}