mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheService provides a simple in-memory cache with TTL
|
||||
type CacheService struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*cacheItem
|
||||
}
|
||||
|
||||
type cacheItem struct {
|
||||
Value interface{}
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
defaultCache *CacheService
|
||||
cacheInitOnce sync.Once
|
||||
)
|
||||
|
||||
// GetCacheService returns singleton cache instance
|
||||
func GetCacheService() *CacheService {
|
||||
cacheInitOnce.Do(func() {
|
||||
defaultCache = &CacheService{
|
||||
items: make(map[string]*cacheItem),
|
||||
}
|
||||
// Start cleanup goroutine
|
||||
go defaultCache.startCleanup()
|
||||
})
|
||||
return defaultCache
|
||||
}
|
||||
|
||||
// Get retrieves value from cache
|
||||
func (cs *CacheService) Get(key string, dest interface{}) error {
|
||||
cs.mu.RLock()
|
||||
item, exists := cs.items[key]
|
||||
cs.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
|
||||
if time.Now().After(item.Expiration) {
|
||||
cs.Delete(key)
|
||||
return fmt.Errorf("key expired")
|
||||
}
|
||||
|
||||
// Type assertion or JSON marshal/unmarshal
|
||||
switch v := item.Value.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(v, dest)
|
||||
default:
|
||||
// Marshal and unmarshal for type conversion
|
||||
data, err := json.Marshal(item.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, dest)
|
||||
}
|
||||
}
|
||||
|
||||
// Set stores value in cache with TTL
|
||||
func (cs *CacheService) Set(key string, value interface{}, ttl time.Duration) error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
cs.items[key] = &cacheItem{
|
||||
Value: value,
|
||||
Expiration: time.Now().Add(ttl),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes item from cache
|
||||
func (cs *CacheService) Delete(key string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
delete(cs.items, key)
|
||||
}
|
||||
|
||||
// Clear removes all items from cache
|
||||
func (cs *CacheService) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.items = make(map[string]*cacheItem)
|
||||
}
|
||||
|
||||
// Has checks if key exists and is not expired
|
||||
func (cs *CacheService) Has(key string) bool {
|
||||
cs.mu.RLock()
|
||||
item, exists := cs.items[key]
|
||||
cs.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if time.Now().After(item.Expiration) {
|
||||
cs.Delete(key)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetOrSet retrieves from cache or executes function and caches result
|
||||
func (cs *CacheService) GetOrSet(key string, dest interface{}, ttl time.Duration, fn func() (interface{}, error)) error {
|
||||
// Try to get from cache
|
||||
err := cs.Get(key, dest)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute function
|
||||
value, err := fn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
if err := cs.Set(key, value, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Marshal and unmarshal for type conversion
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, dest)
|
||||
}
|
||||
|
||||
// startCleanup periodically removes expired items
|
||||
func (cs *CacheService) startCleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
cs.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CacheService) cleanup() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, item := range cs.items {
|
||||
if now.After(item.Expiration) {
|
||||
delete(cs.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns cache statistics
|
||||
func (cs *CacheService) Stats() map[string]interface{} {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
expired := 0
|
||||
now := time.Now()
|
||||
for _, item := range cs.items {
|
||||
if now.After(item.Expiration) {
|
||||
expired++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_items": len(cs.items),
|
||||
"expired_items": expired,
|
||||
"active_items": len(cs.items) - expired,
|
||||
}
|
||||
}
|
||||
|
||||
// CacheKey generates consistent cache keys
|
||||
func CacheKey(parts ...string) string {
|
||||
return "cache:" + joinStrings(parts, ":")
|
||||
}
|
||||
|
||||
func joinStrings(parts []string, sep string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
result += sep + parts[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// WithCache is a decorator for caching function results
|
||||
func WithCache(key string, ttl time.Duration, fn func() (interface{}, error)) (interface{}, error) {
|
||||
cache := GetCacheService()
|
||||
|
||||
// Try cache first
|
||||
var result interface{}
|
||||
if err := cache.Get(key, &result); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Execute function
|
||||
result, err := fn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache result
|
||||
cache.Set(key, result, ttl)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InvalidateCachePattern removes all keys matching pattern
|
||||
func (cs *CacheService) InvalidateCachePattern(pattern string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
for key := range cs.items {
|
||||
if matchesPattern(key, pattern) {
|
||||
delete(cs.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func matchesPattern(key, pattern string) bool {
|
||||
// Simple pattern matching - supports * wildcard
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if pattern contains wildcard
|
||||
if len(pattern) > 0 && pattern[len(pattern)-1] == '*' {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
return len(key) >= len(prefix) && key[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
return key == pattern
|
||||
}
|
||||
|
||||
// WarmupCache preloads frequently accessed data
|
||||
func WarmupCache(ctx context.Context) error {
|
||||
_ = GetCacheService() // Available for warmup logic
|
||||
|
||||
// Example: preload settings
|
||||
// This should be called on application startup
|
||||
|
||||
// Add your warmup logic here
|
||||
// Example:
|
||||
// cache := GetCacheService()
|
||||
// settings, err := fetchSettings()
|
||||
// if err == nil {
|
||||
// cache.Set("settings:main", settings, 1*time.Hour)
|
||||
// }
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"github.com/muesli/clusters"
|
||||
"github.com/muesli/kmeans"
|
||||
)
|
||||
|
||||
type FACRService struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type FACRClubSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Count int `json:"count"`
|
||||
Results []struct {
|
||||
Name string `json:"name"`
|
||||
ClubID string `json:"club_id"`
|
||||
ClubType string `json:"club_type"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Category string `json:"category"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// NewFACRService creates a new FACR service instance
|
||||
func NewFACRService(baseURL string) *FACRService {
|
||||
return &FACRService{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SearchClubs searches for clubs by name
|
||||
func (s *FACRService) SearchClubs(query string) ([]models.ClubSearchResult, error) {
|
||||
if query == "" {
|
||||
return nil, errors.New("search query cannot be empty")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/club/search?q=%s", s.baseURL, query)
|
||||
resp, err := s.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result FACRClubSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
||||
}
|
||||
|
||||
var clubs []models.ClubSearchResult
|
||||
for _, club := range result.Results {
|
||||
clubs = append(clubs, models.ClubSearchResult{
|
||||
ID: club.ClubID,
|
||||
Name: club.Name,
|
||||
Type: club.ClubType,
|
||||
LogoURL: club.LogoURL,
|
||||
Category: club.Category,
|
||||
})
|
||||
}
|
||||
|
||||
return clubs, nil
|
||||
}
|
||||
|
||||
// extractColorsFromLogo extracts dominant colors from a logo image
|
||||
func (s *FACRService) extractColorsFromLogo(logoURL string) (string, string, string, error) {
|
||||
resp, err := s.httpClient.Get(logoURL)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("error downloading logo: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
img, _, err := image.Decode(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("error decoding image: %v", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
var points []clusters.Observation
|
||||
|
||||
// Sample pixels (every 10th pixel for performance)
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y += 10 {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x += 10 {
|
||||
r, g, b, a := img.At(x, y).RGBA()
|
||||
if a > 0x7FFF { // Skip transparent pixels
|
||||
// Convert to float64 slice and create a new observation
|
||||
point := []float64{
|
||||
float64(r >> 8),
|
||||
float64(g >> 8),
|
||||
float64(b >> 8),
|
||||
}
|
||||
points = append(points, clusters.Coordinates(point))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(points) == 0 {
|
||||
return "#1E40AF", "#F59E0B", "#1F2937", nil
|
||||
}
|
||||
|
||||
// Find 3 dominant colors using k-means
|
||||
km := kmeans.New()
|
||||
clusters, err := km.Partition(points, 3) // Find 3 dominant colors
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("error in k-means clustering: %v", err)
|
||||
}
|
||||
|
||||
// Sort clusters by size (number of points in each cluster)
|
||||
for i := 0; i < len(clusters); i++ {
|
||||
for j := i + 1; j < len(clusters); j++ {
|
||||
if len(clusters[j].Observations) > len(clusters[i].Observations) {
|
||||
clusters[i], clusters[j] = clusters[j], clusters[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get primary and secondary colors
|
||||
primary := clusters[0].Center
|
||||
secondary := clusters[1%len(clusters)].Center
|
||||
|
||||
// Calculate text color based on brightness
|
||||
brightness := (0.299*primary[0] + 0.587*primary[1] + 0.114*primary[2]) / 255.0
|
||||
textColor := "#000000"
|
||||
if brightness < 0.5 {
|
||||
textColor = "#FFFFFF"
|
||||
}
|
||||
|
||||
return rgbToHex(primary), rgbToHex(secondary), textColor, nil
|
||||
}
|
||||
|
||||
// rgbToHex converts RGB values to hex color
|
||||
func rgbToHex(rgb []float64) string {
|
||||
return fmt.Sprintf("#%02X%02X%02X",
|
||||
uint8(math.Round(rgb[0])),
|
||||
uint8(math.Round(rgb[1])),
|
||||
uint8(math.Round(rgb[2])),
|
||||
)
|
||||
}
|
||||
|
||||
// GetClubDetails fetches detailed information about a club
|
||||
func (s *FACRService) GetClubDetails(clubID string) (*models.ClubInfo, error) {
|
||||
if clubID == "" {
|
||||
return nil, errors.New("club ID cannot be empty")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/club/football/%s", s.baseURL, clubID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var club struct {
|
||||
Name string `json:"name"`
|
||||
ClubID string `json:"club_id"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&club); err != nil {
|
||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
||||
}
|
||||
|
||||
// Extract colors from logo if available
|
||||
var primaryColor, secondaryColor, textColor string
|
||||
var errExtract error
|
||||
|
||||
if club.LogoURL != "" {
|
||||
primaryColor, secondaryColor, textColor, errExtract = s.extractColorsFromLogo(club.LogoURL)
|
||||
if errExtract != nil {
|
||||
// Log the error but continue with default colors
|
||||
fmt.Printf("Warning: Could not extract colors from logo: %v\n", errExtract)
|
||||
}
|
||||
}
|
||||
|
||||
// Set default colors if extraction failed or wasn't attempted
|
||||
if primaryColor == "" {
|
||||
primaryColor = "#1E40AF" // Blue
|
||||
secondaryColor = "#F59E0B" // Amber
|
||||
textColor = "#1F2937" // Gray-800
|
||||
}
|
||||
|
||||
return &models.ClubInfo{
|
||||
FACRClubID: club.ClubID,
|
||||
Name: club.Name,
|
||||
ShortName: club.Name, // Using name as fallback for short name
|
||||
LogoURL: club.LogoURL,
|
||||
PrimaryColor: primaryColor,
|
||||
SecondaryColor: secondaryColor,
|
||||
TextColor: textColor,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fotbal-club/internal/models"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FileTracker provides utilities for tracking file usage
|
||||
type FileTracker struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// NewFileTracker creates a new file tracker instance
|
||||
func NewFileTracker(db *gorm.DB) *FileTracker {
|
||||
return &FileTracker{DB: db}
|
||||
}
|
||||
|
||||
// TrackFileUpload records a new uploaded file
|
||||
func (ft *FileTracker) TrackFileUpload(filePath, fileURL, filename, mimeType string, fileSize int64, uploadedByID *uint) error {
|
||||
file := models.UploadedFile{
|
||||
Filename: filename,
|
||||
FilePath: filePath,
|
||||
FileURL: fileURL,
|
||||
FileSize: fileSize,
|
||||
MimeType: mimeType,
|
||||
UploadedByID: uploadedByID,
|
||||
}
|
||||
|
||||
return ft.DB.Create(&file).Error
|
||||
}
|
||||
|
||||
// TrackFileUsage records or updates file usage for an entity
|
||||
func (ft *FileTracker) TrackFileUsage(fileURL, entityType string, entityID uint, fieldName string) error {
|
||||
if fileURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the file by URL
|
||||
var file models.UploadedFile
|
||||
if err := ft.DB.Where("file_url = ?", fileURL).First(&file).Error; err != nil {
|
||||
// File not found in database - skip tracking
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if usage already exists
|
||||
var existingUsage models.FileUsage
|
||||
err := ft.DB.Where("file_id = ? AND entity_type = ? AND entity_id = ? AND field_name = ?",
|
||||
file.ID, entityType, entityID, fieldName).First(&existingUsage).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new usage record
|
||||
usage := models.FileUsage{
|
||||
FileID: file.ID,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
FieldName: fieldName,
|
||||
}
|
||||
return ft.DB.Create(&usage).Error
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveFileUsage removes a file usage record
|
||||
func (ft *FileTracker) RemoveFileUsage(fileURL, entityType string, entityID uint, fieldName string) error {
|
||||
if fileURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the file by URL
|
||||
var file models.UploadedFile
|
||||
if err := ft.DB.Where("file_url = ?", fileURL).First(&file).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ft.DB.Where("file_id = ? AND entity_type = ? AND entity_id = ? AND field_name = ?",
|
||||
file.ID, entityType, entityID, fieldName).Delete(&models.FileUsage{}).Error
|
||||
}
|
||||
|
||||
// UpdateFileUsages updates all file usages for an entity (removes old, adds new)
|
||||
func (ft *FileTracker) UpdateFileUsages(entityType string, entityID uint, fieldURLMap map[string]string) error {
|
||||
// Get all current usages for this entity
|
||||
var currentUsages []models.FileUsage
|
||||
ft.DB.Where("entity_type = ? AND entity_id = ?", entityType, entityID).Find(¤tUsages)
|
||||
|
||||
// Create a map of current usages
|
||||
currentMap := make(map[string]models.FileUsage)
|
||||
for _, usage := range currentUsages {
|
||||
key := usage.FieldName
|
||||
currentMap[key] = usage
|
||||
}
|
||||
|
||||
// Track new usages
|
||||
for fieldName, fileURL := range fieldURLMap {
|
||||
if fileURL != "" {
|
||||
// Check if already tracked
|
||||
if _, exists := currentMap[fieldName]; !exists {
|
||||
ft.TrackFileUsage(fileURL, entityType, entityID, fieldName)
|
||||
}
|
||||
delete(currentMap, fieldName) // Remove from current map
|
||||
}
|
||||
}
|
||||
|
||||
// Remove usages that are no longer present
|
||||
for fieldName := range currentMap {
|
||||
ft.DB.Where("entity_type = ? AND entity_id = ? AND field_name = ?",
|
||||
entityType, entityID, fieldName).Delete(&models.FileUsage{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TrackArticleFiles tracks all file usages in an article
|
||||
func (ft *FileTracker) TrackArticleFiles(article *models.Article) error {
|
||||
fieldURLMap := map[string]string{
|
||||
"image_url": article.ImageURL,
|
||||
"og_image_url": article.OGImageURL,
|
||||
}
|
||||
|
||||
// Track attachments if present
|
||||
if article.Attachments != "" {
|
||||
// Attachments is a JSON array of URLs
|
||||
// For simplicity, we'll track each attachment URL separately
|
||||
// You might want to parse the JSON properly in production
|
||||
attachments := strings.Split(article.Attachments, ",")
|
||||
for i, attachment := range attachments {
|
||||
attachment = strings.Trim(attachment, `[]" `)
|
||||
if attachment != "" {
|
||||
fieldName := "attachments"
|
||||
if i > 0 {
|
||||
// If multiple attachments, differentiate them
|
||||
fieldName = "attachments"
|
||||
}
|
||||
fieldURLMap[fieldName] = attachment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ft.UpdateFileUsages("article", article.ID, fieldURLMap)
|
||||
}
|
||||
|
||||
// TrackPlayerFiles tracks all file usages in a player
|
||||
func (ft *FileTracker) TrackPlayerFiles(player *models.Player) error {
|
||||
fieldURLMap := map[string]string{
|
||||
"image_url": player.ImageURL,
|
||||
}
|
||||
return ft.UpdateFileUsages("player", player.ID, fieldURLMap)
|
||||
}
|
||||
|
||||
// TrackSponsorFiles tracks all file usages in a sponsor
|
||||
func (ft *FileTracker) TrackSponsorFiles(sponsor *models.Sponsor) error {
|
||||
fieldURLMap := map[string]string{
|
||||
"logo_url": sponsor.LogoURL,
|
||||
}
|
||||
return ft.UpdateFileUsages("sponsor", sponsor.ID, fieldURLMap)
|
||||
}
|
||||
|
||||
// TrackEventFiles tracks all file usages in an event
|
||||
func (ft *FileTracker) TrackEventFiles(event *models.Event) error {
|
||||
fieldURLMap := map[string]string{
|
||||
"image_url": event.ImageURL,
|
||||
"file_url": event.FileURL,
|
||||
}
|
||||
return ft.UpdateFileUsages("event", event.ID, fieldURLMap)
|
||||
}
|
||||
|
||||
// TrackContactFiles tracks all file usages in a contact
|
||||
func (ft *FileTracker) TrackContactFiles(contact *models.Contact) error {
|
||||
fieldURLMap := map[string]string{
|
||||
"image_url": contact.ImageURL,
|
||||
}
|
||||
return ft.UpdateFileUsages("contact", contact.ID, fieldURLMap)
|
||||
}
|
||||
|
||||
// TrackSettingsFiles tracks all file usages in settings
|
||||
func (ft *FileTracker) TrackSettingsFiles(settings *models.Settings) error {
|
||||
fieldURLMap := map[string]string{
|
||||
"default_og_image_url": settings.DefaultOGImageURL,
|
||||
"club_logo_url": settings.ClubLogoURL,
|
||||
}
|
||||
return ft.UpdateFileUsages("settings", settings.ID, fieldURLMap)
|
||||
}
|
||||
|
||||
// TrackTeamFiles tracks all file usages in a team
|
||||
func (ft *FileTracker) TrackTeamFiles(team *models.Team) error {
|
||||
fieldURLMap := map[string]string{
|
||||
"logo_url": team.LogoURL,
|
||||
}
|
||||
return ft.UpdateFileUsages("team", team.ID, fieldURLMap)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Note: To use this image optimizer, install the required package:
|
||||
// go get golang.org/x/image/draw
|
||||
//
|
||||
// For now, we'll use basic resizing without the external library
|
||||
// Uncomment the import above and the advanced resize function when ready
|
||||
|
||||
// ImageSize defines thumbnail dimensions
|
||||
type ImageSize struct {
|
||||
Width int
|
||||
Height int
|
||||
Name string
|
||||
}
|
||||
|
||||
var (
|
||||
// StandardSizes for responsive images
|
||||
StandardSizes = []ImageSize{
|
||||
{Width: 150, Height: 150, Name: "thumb"},
|
||||
{Width: 400, Height: 400, Name: "small"},
|
||||
{Width: 800, Height: 800, Name: "medium"},
|
||||
{Width: 1200, Height: 1200, Name: "large"},
|
||||
}
|
||||
)
|
||||
|
||||
// OptimizedImage holds paths to all generated sizes
|
||||
type OptimizedImage struct {
|
||||
Original string
|
||||
Thumb string
|
||||
Small string
|
||||
Medium string
|
||||
Large string
|
||||
}
|
||||
|
||||
// OptimizeAndResize processes an uploaded image
|
||||
func OptimizeAndResize(sourcePath string) (*OptimizedImage, error) {
|
||||
// Open source image
|
||||
file, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open image: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Decode image
|
||||
img, format, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
result := &OptimizedImage{
|
||||
Original: sourcePath,
|
||||
}
|
||||
|
||||
// Generate thumbnails
|
||||
dir := filepath.Dir(sourcePath)
|
||||
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||
|
||||
for _, size := range StandardSizes {
|
||||
resized := resizeImage(img, size.Width, size.Height)
|
||||
outputPath := filepath.Join(dir, fmt.Sprintf("%s_%s.jpg", base, size.Name))
|
||||
|
||||
if err := saveJPEG(resized, outputPath, 85); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Store path in result
|
||||
switch size.Name {
|
||||
case "thumb":
|
||||
result.Thumb = outputPath
|
||||
case "small":
|
||||
result.Small = outputPath
|
||||
case "medium":
|
||||
result.Medium = outputPath
|
||||
case "large":
|
||||
result.Large = outputPath
|
||||
}
|
||||
}
|
||||
|
||||
// Optimize original if it's too large
|
||||
if format == "jpeg" || format == "jpg" {
|
||||
optimizeJPEG(sourcePath)
|
||||
} else if format == "png" {
|
||||
convertPNGToJPEG(sourcePath)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resizeImage resizes image maintaining aspect ratio
|
||||
// Using simple nearest-neighbor scaling (for production, consider golang.org/x/image/draw)
|
||||
func resizeImage(src image.Image, maxWidth, maxHeight int) image.Image {
|
||||
srcBounds := src.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
ratio := float64(srcW) / float64(srcH)
|
||||
var newW, newH int
|
||||
|
||||
if srcW > srcH {
|
||||
newW = maxWidth
|
||||
newH = int(float64(maxWidth) / ratio)
|
||||
} else {
|
||||
newH = maxHeight
|
||||
newW = int(float64(maxHeight) * ratio)
|
||||
}
|
||||
|
||||
// Don't upscale
|
||||
if newW > srcW || newH > srcH {
|
||||
return src
|
||||
}
|
||||
|
||||
// Simple nearest-neighbor resize
|
||||
// For production quality, use: golang.org/x/image/draw with CatmullRom
|
||||
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||||
xRatio := float64(srcW) / float64(newW)
|
||||
yRatio := float64(srcH) / float64(newH)
|
||||
|
||||
for y := 0; y < newH; y++ {
|
||||
for x := 0; x < newW; x++ {
|
||||
srcX := int(float64(x) * xRatio)
|
||||
srcY := int(float64(y) * yRatio)
|
||||
dst.Set(x, y, src.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// saveJPEG saves image as JPEG with specified quality
|
||||
func saveJPEG(img image.Image, path string, quality int) error {
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
return jpeg.Encode(out, img, &jpeg.Options{Quality: quality})
|
||||
}
|
||||
|
||||
// optimizeJPEG re-encodes JPEG with optimal quality
|
||||
func optimizeJPEG(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
img, err := jpeg.Decode(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
tmpPath := path + ".tmp"
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encode with 85% quality
|
||||
err = jpeg.Encode(out, img, &jpeg.Options{Quality: 85})
|
||||
out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if new file is smaller
|
||||
origInfo, _ := os.Stat(path)
|
||||
newInfo, _ := os.Stat(tmpPath)
|
||||
|
||||
if newInfo.Size() < origInfo.Size() {
|
||||
// Replace original
|
||||
os.Remove(path)
|
||||
os.Rename(tmpPath, path)
|
||||
} else {
|
||||
// Keep original
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertPNGToJPEG converts PNG to JPEG for better compression
|
||||
func convertPNGToJPEG(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
img, err := png.Decode(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create JPEG version
|
||||
jpegPath := strings.TrimSuffix(path, ".png") + ".jpg"
|
||||
out, err := os.Create(jpegPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
return jpeg.Encode(out, img, &jpeg.Options{Quality: 90})
|
||||
}
|
||||
|
||||
// GetImageDimensions returns width and height of an image
|
||||
func GetImageDimensions(path string) (int, int, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
config, _, err := image.DecodeConfig(file)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return config.Width, config.Height, nil
|
||||
}
|
||||
|
||||
// ValidateImageFile checks if file is a valid image
|
||||
func ValidateImageFile(reader io.Reader) (string, error) {
|
||||
// Read first 512 bytes for MIME detection
|
||||
buf := make([]byte, 512)
|
||||
n, err := io.ReadFull(reader, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try to decode as image
|
||||
_, format, err := image.Decode(bytes.NewReader(buf[:n]))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("not a valid image: %w", err)
|
||||
}
|
||||
|
||||
// Validate format
|
||||
validFormats := map[string]bool{
|
||||
"jpeg": true,
|
||||
"jpg": true,
|
||||
"png": true,
|
||||
"gif": true,
|
||||
"webp": true,
|
||||
}
|
||||
|
||||
if !validFormats[format] {
|
||||
return "", fmt.Errorf("unsupported image format: %s", format)
|
||||
}
|
||||
|
||||
return format, nil
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/email"
|
||||
"fotbal-club/pkg/logger"
|
||||
"fotbal-club/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NewsletterAutomation handles all automated newsletter sending
|
||||
type NewsletterAutomation struct {
|
||||
db *gorm.DB
|
||||
emailSvc email.EmailService
|
||||
cacheDir string
|
||||
lastWeekly time.Time
|
||||
lastMatchCheck time.Time
|
||||
}
|
||||
|
||||
// NewNewsletterAutomation creates a new automation service
|
||||
func NewNewsletterAutomation(db *gorm.DB, emailSvc email.EmailService) *NewsletterAutomation {
|
||||
return &NewsletterAutomation{
|
||||
db: db,
|
||||
emailSvc: emailSvc,
|
||||
cacheDir: filepath.Join("cache", "prefetch"),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the newsletter automation loop
|
||||
func (na *NewsletterAutomation) Start() {
|
||||
log.Printf("[newsletter-automation] Starting automated newsletter service")
|
||||
|
||||
// Run initial check after 1 minute
|
||||
time.AfterFunc(1*time.Minute, func() {
|
||||
na.RunCycle()
|
||||
})
|
||||
|
||||
// Then run every 15 minutes
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
na.RunCycle()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RunCycle executes all newsletter checks
|
||||
func (na *NewsletterAutomation) RunCycle() {
|
||||
if !na.isEnabled() {
|
||||
log.Printf("[newsletter-automation] Skipped: disabled in settings")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[newsletter-automation] Running cycle...")
|
||||
|
||||
// Check for weekly digest
|
||||
na.checkWeeklyDigest()
|
||||
|
||||
// Check for upcoming matches (reminders)
|
||||
na.checkUpcomingMatches()
|
||||
|
||||
// Check for finished matches (results)
|
||||
na.checkFinishedMatches()
|
||||
|
||||
log.Printf("[newsletter-automation] Cycle complete")
|
||||
}
|
||||
|
||||
// SendBlogNotification sends immediate notification when a blog is published
|
||||
func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) error {
|
||||
if !na.isEnabled() {
|
||||
return fmt.Errorf("newsletter automation is disabled")
|
||||
}
|
||||
|
||||
// Check if already sent
|
||||
var existing models.BlogNotification
|
||||
if err := na.db.Where("article_id = ?", article.ID).First(&existing).Error; err == nil {
|
||||
log.Printf("[newsletter-automation] Blog notification already sent for article %d", article.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get subscribers interested in blogs
|
||||
subs := na.getSubscribersForType("blogs", article.CategoryName)
|
||||
if len(subs) == 0 {
|
||||
log.Printf("[newsletter-automation] No subscribers for blog notifications")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build email content
|
||||
subject := fmt.Sprintf("Nový článek: %s", article.Title)
|
||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||
articleURL := fmt.Sprintf("%s/news/%s", baseFE, article.Slug)
|
||||
|
||||
html := na.buildBlogNotificationHTML(article, articleURL)
|
||||
|
||||
// Send to each subscriber
|
||||
recipients := make([]string, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
recipients = append(recipients, sub.Email)
|
||||
}
|
||||
|
||||
err := na.sendNewsletterToRecipients(recipients, subject, html, "blog_release")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send blog notification: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Record notification
|
||||
notif := models.BlogNotification{
|
||||
ArticleID: article.ID,
|
||||
SentAt: time.Now(),
|
||||
RecipientsCount: len(recipients),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(¬if)
|
||||
|
||||
log.Printf("[newsletter-automation] Blog notification sent for article %d to %d recipients", article.ID, len(recipients))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) checkWeeklyDigest() {
|
||||
var settings models.Settings
|
||||
na.db.First(&settings)
|
||||
|
||||
if !settings.EnableWeekly {
|
||||
return
|
||||
}
|
||||
|
||||
// Get configured day and hour
|
||||
targetDay := strings.ToLower(strings.TrimSpace(settings.NewsletterWeeklyDay))
|
||||
if targetDay == "" {
|
||||
targetDay = "sun" // Default to Sunday
|
||||
}
|
||||
targetHour := settings.NewsletterWeeklyHour
|
||||
if targetHour < 0 || targetHour > 23 {
|
||||
targetHour = 9 // Default to 9 AM
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
currentDay := strings.ToLower(now.Weekday().String()[:3])
|
||||
currentHour := now.Hour()
|
||||
|
||||
// Check if it's the right day and hour
|
||||
if currentDay != targetDay || currentHour != targetHour {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already sent today
|
||||
if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all subscribers interested in weekly digest
|
||||
subs := na.getSubscribersForType("weekly", "")
|
||||
if len(subs) == 0 {
|
||||
log.Printf("[newsletter-automation] No subscribers for weekly digest")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[newsletter-automation] Sending weekly digest to %d subscribers", len(subs))
|
||||
|
||||
// Build weekly content for each subscriber based on their preferences
|
||||
for _, sub := range subs {
|
||||
prefs := na.parsePreferences(sub)
|
||||
subject, html := BuildNewsletterDigest(na.cacheDir, prefs)
|
||||
|
||||
if strings.TrimSpace(html) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
err := na.sendNewsletterToRecipients([]string{sub.Email}, subject, html, "weekly")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send weekly digest to %s: %v", sub.Email, err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond) // Rate limiting
|
||||
}
|
||||
|
||||
na.lastWeekly = now
|
||||
log.Printf("[newsletter-automation] Weekly digest sent")
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) checkUpcomingMatches() {
|
||||
var settings models.Settings
|
||||
na.db.First(&settings)
|
||||
|
||||
if !settings.EnableMatchReminders {
|
||||
return
|
||||
}
|
||||
|
||||
leadHours := settings.NewsletterReminderLeadHours
|
||||
if leadHours <= 0 {
|
||||
leadHours = 48 // Default 2 days
|
||||
}
|
||||
|
||||
// Load match data from cache
|
||||
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
|
||||
matches := facrAllMatches(facr)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, match := range matches {
|
||||
matchTime := parseDateTimeISO(match.Date, match.Time)
|
||||
if matchTime.IsZero() || matchTime.Before(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
hoursUntil := matchTime.Sub(now).Hours()
|
||||
|
||||
// Check for 48h reminder
|
||||
if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) {
|
||||
na.sendMatchReminder(match, "reminder_48h", leadHours)
|
||||
}
|
||||
|
||||
// Check for day-of reminder (match starts in 0-6 hours)
|
||||
if hoursUntil <= 6 && hoursUntil > 0 {
|
||||
na.sendMatchReminder(match, "reminder_day", 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, hoursBeforeText int) {
|
||||
// Check if already sent
|
||||
var existing models.MatchNotification
|
||||
matchKey := fmt.Sprintf("%s-%s-%s", match.Date, match.Home, match.Away)
|
||||
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get subscribers interested in matches and this competition
|
||||
subs := na.getSubscribersForType("matches", match.Competition)
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build email content
|
||||
var subject string
|
||||
if notifType == "reminder_48h" {
|
||||
subject = fmt.Sprintf("Nadcházející zápas za %d hodin: %s vs %s", hoursBeforeText, match.Home, match.Away)
|
||||
} else {
|
||||
subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away)
|
||||
}
|
||||
|
||||
html := na.buildMatchReminderHTML(match, notifType)
|
||||
|
||||
recipients := make([]string, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
recipients = append(recipients, sub.Email)
|
||||
}
|
||||
|
||||
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send match reminder: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Record notification
|
||||
notif := models.MatchNotification{
|
||||
MatchID: matchKey,
|
||||
NotificationType: notifType,
|
||||
SentAt: time.Now(),
|
||||
RecipientsCount: len(recipients),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(¬if)
|
||||
|
||||
log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients))
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) checkFinishedMatches() {
|
||||
var settings models.Settings
|
||||
na.db.First(&settings)
|
||||
|
||||
if !settings.EnableResults {
|
||||
return
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
currentHour := time.Now().Hour()
|
||||
quietStart := settings.NewsletterQuietStart
|
||||
quietEnd := settings.NewsletterQuietEnd
|
||||
|
||||
if quietStart > 0 && quietEnd > 0 {
|
||||
if quietStart < quietEnd {
|
||||
// e.g., 22:00 - 08:00
|
||||
if currentHour >= quietStart || currentHour < quietEnd {
|
||||
log.Printf("[newsletter-automation] In quiet hours, skipping result notifications")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// e.g., 08:00 - 22:00 (inverted, send only during these hours)
|
||||
if currentHour < quietStart && currentHour >= quietEnd {
|
||||
log.Printf("[newsletter-automation] Outside active hours, skipping result notifications")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load match data
|
||||
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
|
||||
matches := facrAllMatches(facr)
|
||||
|
||||
now := time.Now()
|
||||
lookback := 6 * time.Hour // Check matches finished in last 6 hours
|
||||
|
||||
for _, match := range matches {
|
||||
if match.Score == "" || !strings.Contains(match.Score, ":") {
|
||||
continue // No score yet
|
||||
}
|
||||
|
||||
matchTime := parseDateTimeISO(match.Date, match.Time)
|
||||
if matchTime.IsZero() || matchTime.After(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if match finished recently
|
||||
timeSinceMatch := now.Sub(matchTime)
|
||||
if timeSinceMatch > lookback {
|
||||
continue
|
||||
}
|
||||
|
||||
na.sendMatchResult(match)
|
||||
}
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) sendMatchResult(match Match) {
|
||||
// Check if already sent
|
||||
matchKey := fmt.Sprintf("%s-%s-%s", match.Date, match.Home, match.Away)
|
||||
var existing models.MatchNotification
|
||||
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get subscribers interested in results
|
||||
subs := na.getSubscribersForType("scores", match.Competition)
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away)
|
||||
html := na.buildMatchResultHTML(match)
|
||||
|
||||
recipients := make([]string, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
recipients = append(recipients, sub.Email)
|
||||
}
|
||||
|
||||
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send match result: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Record notification
|
||||
notif := models.MatchNotification{
|
||||
MatchID: matchKey,
|
||||
NotificationType: "result",
|
||||
SentAt: time.Now(),
|
||||
RecipientsCount: len(recipients),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(¬if)
|
||||
|
||||
log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients))
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (na *NewsletterAutomation) isEnabled() bool {
|
||||
if config.AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
return config.AppConfig.NewsletterEnabled
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription {
|
||||
var subs []models.NewsletterSubscription
|
||||
na.db.Where("is_active = ?", true).Find(&subs)
|
||||
|
||||
filtered := make([]models.NewsletterSubscription, 0)
|
||||
for _, sub := range subs {
|
||||
// Check if subscriber wants this content type
|
||||
if val, ok := sub.Preferences[contentType].(bool); ok && val {
|
||||
// If category filtering is needed and specified
|
||||
if category != "" {
|
||||
// Check if subscriber has category preferences
|
||||
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
|
||||
categoryList := strings.Split(cats, ",")
|
||||
found := false
|
||||
for _, cat := range categoryList {
|
||||
if strings.EqualFold(strings.TrimSpace(cat), category) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, sub)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscription) NewsletterPrefs {
|
||||
prefs := NewsletterPrefs{
|
||||
Email: sub.Email,
|
||||
ContentTypes: []string{},
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
|
||||
// Parse content types
|
||||
if v, ok := sub.Preferences["blogs"].(bool); ok && v {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
|
||||
}
|
||||
if v, ok := sub.Preferences["events"].(bool); ok && v {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "events")
|
||||
}
|
||||
if v, ok := sub.Preferences["matches"].(bool); ok && v {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "matches")
|
||||
}
|
||||
if v, ok := sub.Preferences["scores"].(bool); ok && v {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
|
||||
}
|
||||
|
||||
// Parse categories/competitions
|
||||
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
|
||||
for _, c := range strings.Split(cats, ",") {
|
||||
if v := strings.TrimSpace(c); v != "" {
|
||||
prefs.Competitions = append(prefs.Competitions, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prefs
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string, subject, htmlContent, newsletterType string) error {
|
||||
data := &email.NewsletterData{
|
||||
Subject: subject,
|
||||
Content: htmlContent,
|
||||
Recipients: recipients,
|
||||
}
|
||||
|
||||
err := na.emailSvc.SendNewsletter(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log sent newsletter
|
||||
contentIDsJSON, _ := json.Marshal([]string{})
|
||||
logEntry := models.NewsletterSentLog{
|
||||
NewsletterType: newsletterType,
|
||||
Subject: subject,
|
||||
ContentIDs: string(contentIDsJSON),
|
||||
RecipientsCount: len(recipients),
|
||||
SentAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(&logEntry)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTML builders
|
||||
|
||||
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
|
||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||
|
||||
// Build tracked link
|
||||
token, _ := utils.GenerateSubscriberToken("newsletter@system", 60*24*30)
|
||||
trackedURL := fmt.Sprintf("%s/api/v1/email/click?u=%s&t=%s",
|
||||
strings.TrimSuffix(config.AppConfig.PublicAPIBaseURL, "/"),
|
||||
url.QueryEscape(articleURL),
|
||||
url.QueryEscape(token))
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
|
||||
|
||||
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">%s</h3>
|
||||
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">%s</p>
|
||||
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
|
||||
<a href="%s/newsletter/preferences?token=%s" style="color: #2563eb;">Spravovat předvolby</a>
|
||||
</p>
|
||||
</div>
|
||||
`, htmlEsc(article.Title), htmlEsc(article.Excerpt), trackedURL, baseFE, url.QueryEscape(token))
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
|
||||
var intro string
|
||||
if notifType == "reminder_48h" {
|
||||
intro = "Připomínáme nadcházející zápas:"
|
||||
} else {
|
||||
intro = "Zápas je dnes!"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
|
||||
|
||||
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">%s vs %s</h3>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> %s</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> %s</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> %s</p>
|
||||
</div>
|
||||
</div>
|
||||
`, intro, htmlEsc(match.Home), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Time), htmlEsc(match.Competition))
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) buildMatchResultHTML(match Match) string {
|
||||
html := fmt.Sprintf(`
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Výsledek zápasu</h2>
|
||||
|
||||
<div style="border-left: 4px solid #d69e2e; padding: 20px; background: #fffbeb; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">%s <span style="color: #d69e2e;">%s</span> %s</h3>
|
||||
<p style="color: #975a16; margin: 5px 0;"><strong>Datum:</strong> %s</p>
|
||||
<p style="color: #975a16; margin: 5px 0;"><strong>Soutěž:</strong> %s</p>
|
||||
</div>
|
||||
</div>
|
||||
`, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition))
|
||||
|
||||
return html
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewsletterPrefs represents the normalized subset of subscriber preferences
|
||||
// we care about when generating automated content.
|
||||
type NewsletterPrefs struct {
|
||||
Email string `json:"email"`
|
||||
ContentTypes []string `json:"content_types"` // blogs, matches, scores, events
|
||||
Competitions []string `json:"competitions"` // FACR codes
|
||||
Frequency string `json:"frequency"` // daily, weekly, matchday
|
||||
}
|
||||
|
||||
// BuildNewsletterDigest builds an HTML digest string and subject based on cached JSON
|
||||
// and subscriber preferences. It is robust to cache shape differences.
|
||||
func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject string, html string) {
|
||||
// Normalize content types
|
||||
want := make(map[string]bool)
|
||||
for _, c := range prefs.ContentTypes {
|
||||
want[strings.ToLower(strings.TrimSpace(c))] = true
|
||||
}
|
||||
if len(want) == 0 {
|
||||
// default to blogs + matches
|
||||
want["blogs"] = true
|
||||
want["matches"] = true
|
||||
}
|
||||
|
||||
// Load caches (best-effort)
|
||||
art := readJSON(filepath.Join(cacheDir, "articles.json"))
|
||||
ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json"))
|
||||
facr:= readJSON(filepath.Join(cacheDir, "facr_club_info.json"))
|
||||
|
||||
sections := make([]string, 0, 4)
|
||||
|
||||
// Blogs/articles
|
||||
if want["blogs"] {
|
||||
items := pickArticles(art, 6)
|
||||
if len(items) > 0 {
|
||||
sections = append(sections, renderArticlesSection(items))
|
||||
}
|
||||
}
|
||||
|
||||
// Upcoming events
|
||||
if want["events"] || want["matches"] {
|
||||
items := pickUpcomingEvents(ev, 6)
|
||||
if len(items) > 0 {
|
||||
sections = append(sections, renderEventsSection(items))
|
||||
}
|
||||
}
|
||||
|
||||
// Matches (from FACR club info)
|
||||
if want["matches"] {
|
||||
items := pickUpcomingMatchesFromFACR(facr, prefs.Competitions, 6)
|
||||
if len(items) > 0 {
|
||||
sections = append(sections, renderMatchesSection(items))
|
||||
}
|
||||
}
|
||||
|
||||
// Scores/results digest (past week) from FACR club info
|
||||
if want["scores"] {
|
||||
items := pickRecentResultsFromFACR(facr, prefs.Competitions, 8, 7*24*time.Hour)
|
||||
if len(items) > 0 {
|
||||
sections = append(sections, renderResultsSection(items))
|
||||
}
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return "Fotbal Club – přehled", "<p>Pro vybrané preference nyní nemáme novinky.</p>"
|
||||
}
|
||||
|
||||
subject = "Fotbal Club – novinky a zápasy"
|
||||
html = strings.Join(sections, "\n\n")
|
||||
return subject, html
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
func readJSON(path string) any {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) == 0 { return nil }
|
||||
var v any
|
||||
_ = json.Unmarshal(b, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
type Article struct { Title, Url, Image, Excerpt string; Date time.Time }
|
||||
|
||||
func pickArticles(v any, n int) []Article {
|
||||
// Accept shapes: {items:[]}, {data:[]}, []
|
||||
list := asList(v)
|
||||
out := make([]Article, 0, n)
|
||||
for i, it := range list {
|
||||
if i >= n { break }
|
||||
m := asMap(it)
|
||||
a := Article{
|
||||
Title: str(m["title"], str(m["name"], "Článek")),
|
||||
Url: str(m["url"], urlFromSlug(m)),
|
||||
Image: str(m["imageUrl"], str(m["image_url"], str(m["image"], ""))),
|
||||
Excerpt: str(m["excerpt"], str(m["summary"], "")),
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Event struct { Title, Date, Time, Url string }
|
||||
|
||||
func pickUpcomingEvents(v any, n int) []Event {
|
||||
list := asList(v)
|
||||
out := make([]Event, 0, n)
|
||||
for i, it := range list {
|
||||
if i >= n { break }
|
||||
m := asMap(it)
|
||||
e := Event{
|
||||
Title: str(m["title"], str(m["name"], "Událost")),
|
||||
Date: str(m["date"], ""),
|
||||
Time: str(m["time"], ""),
|
||||
Url: str(m["url"], ""),
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Match struct { Home, Away, Date, Time, Competition, Link, Score string }
|
||||
|
||||
func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
|
||||
compSet := make(map[string]bool)
|
||||
for _, c := range competitions { compSet[strings.TrimSpace(strings.ToLower(c))] = true }
|
||||
now := time.Now()
|
||||
list := facrAllMatches(v)
|
||||
out := make([]Match, 0, n)
|
||||
for _, m := range list {
|
||||
ts := parseDateTimeISO(m.Date, m.Time)
|
||||
if ts.IsZero() || ts.Before(now) { continue }
|
||||
if len(compSet) > 0 {
|
||||
if !compSet[strings.ToLower(m.Competition)] { continue }
|
||||
}
|
||||
out = append(out, m)
|
||||
if len(out) >= n { break }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickRecentResultsFromFACR(v any, competitions []string, n int, window time.Duration) []Match {
|
||||
compSet := make(map[string]bool)
|
||||
for _, c := range competitions { compSet[strings.TrimSpace(strings.ToLower(c))] = true }
|
||||
now := time.Now()
|
||||
from := now.Add(-window)
|
||||
list := facrAllMatches(v)
|
||||
out := make([]Match, 0, n)
|
||||
for _, m := range list {
|
||||
ts := parseDateTimeISO(m.Date, m.Time)
|
||||
if ts.IsZero() || ts.After(now) || ts.Before(from) { continue }
|
||||
// treat as result if score like "2:1" exists
|
||||
if m.Score == "" || !strings.Contains(m.Score, ":") { continue }
|
||||
if len(compSet) > 0 {
|
||||
if !compSet[strings.ToLower(m.Competition)] { continue }
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
// Show latest first
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ti := parseDateTimeISO(out[i].Date, out[i].Time)
|
||||
tj := parseDateTimeISO(out[j].Date, out[j].Time)
|
||||
return ti.After(tj)
|
||||
})
|
||||
if len(out) > n { out = out[:n] }
|
||||
return out
|
||||
}
|
||||
|
||||
// FACR helpers (robust to various shapes)
|
||||
|
||||
func facrAllMatches(v any) []Match {
|
||||
out := []Match{}
|
||||
if v == nil { return out }
|
||||
m := asMap(v)
|
||||
// competitions array
|
||||
if comps, ok := m["competitions"]; ok {
|
||||
for _, c := range asList(comps) {
|
||||
cm := asMap(c)
|
||||
compName := str(cm["name"], str(cm["code"], ""))
|
||||
for _, mm := range asList(cm["matches"]) {
|
||||
out = append(out, toMatch(asMap(mm), compName))
|
||||
}
|
||||
}
|
||||
}
|
||||
// flat matches fallback
|
||||
for _, mm := range asList(m["matches"]) {
|
||||
out = append(out, toMatch(asMap(mm), ""))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toMatch(m map[string]any, comp string) Match {
|
||||
dt := str(m["date_time"], "")
|
||||
var date, tm string
|
||||
if dt != "" && strings.Contains(dt, " ") {
|
||||
parts := strings.SplitN(dt, " ", 2)
|
||||
date, tm = parts[0], parts[1]
|
||||
} else {
|
||||
date = str(m["date"], "")
|
||||
tm = str(m["time"], "")
|
||||
}
|
||||
return Match{
|
||||
Home: str(m["home"], ""),
|
||||
Away: str(m["away"], ""),
|
||||
Date: date,
|
||||
Time: tm,
|
||||
Competition: str(m["competition"], str(m["competition_name"], comp)),
|
||||
Link: str(m["facr_link"], str(m["report_url"], "#")),
|
||||
Score: str(m["score"], ""),
|
||||
}
|
||||
}
|
||||
|
||||
func parseDateTimeISO(d, t string) time.Time {
|
||||
if d == "" { return time.Time{} }
|
||||
if t == "" { t = "00:00" }
|
||||
layout := "2006-01-02T15:04:05"
|
||||
// try shorter HH:MM format
|
||||
if len(t) == 5 { return parseTime("2006-01-02T15:04", d+"T"+t) }
|
||||
return parseTime(layout, d+"T"+t)
|
||||
}
|
||||
|
||||
func parseTime(layout, s string) time.Time {
|
||||
if tm, err := time.Parse(layout, s); err == nil { return tm }
|
||||
// Try local
|
||||
if tm, err := time.ParseInLocation(layout, s, time.Local); err == nil { return tm }
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Render helpers (inline styles for email)
|
||||
|
||||
func renderArticlesSection(items []Article) string {
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "<h2 style='margin:0 0 12px 0;'>Články</h2>")
|
||||
for _, a := range items {
|
||||
fmt.Fprintf(b, "<div style='margin:10px 0;padding:10px;border-left:4px solid #2b6cb0;background:#f8fafc;'>")
|
||||
if a.Title != "" { fmt.Fprintf(b, "<div style='font-weight:600;'>%s</div>", htmlEsc(a.Title)) }
|
||||
if a.Excerpt != "" { fmt.Fprintf(b, "<div style='color:#4a5568;font-size:14px;'>%s</div>", htmlEsc(a.Excerpt)) }
|
||||
if a.Url != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Číst více</a></div>", a.Url) }
|
||||
fmt.Fprintf(b, "</div>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderEventsSection(items []Event) string {
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Události</h2>")
|
||||
for _, e := range items {
|
||||
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #3182ce;background:#ebf8ff;'>")
|
||||
fmt.Fprintf(b, "<div style='font-weight:600;'>%s</div>", htmlEsc(e.Title))
|
||||
if e.Date != "" || e.Time != "" {
|
||||
fmt.Fprintf(b, "<div style='color:#2c5282;font-size:14px;'>%s %s</div>", htmlEsc(e.Date), htmlEsc(e.Time))
|
||||
}
|
||||
if e.Url != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Zobrazit</a></div>", e.Url) }
|
||||
fmt.Fprintf(b, "</div>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderMatchesSection(items []Match) string {
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Nejbližší zápasy</h2>")
|
||||
for _, m := range items {
|
||||
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #38a169;background:#f0fff4;'>")
|
||||
fmt.Fprintf(b, "<div style='font-weight:600;'>%s vs %s</div>", htmlEsc(m.Home), htmlEsc(m.Away))
|
||||
meta := strings.TrimSpace(fmt.Sprintf("%s %s · %s", m.Date, m.Time, m.Competition))
|
||||
fmt.Fprintf(b, "<div style='color:#276749;font-size:14px;'>%s</div>", htmlEsc(meta))
|
||||
if m.Link != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Detail</a></div>", m.Link) }
|
||||
fmt.Fprintf(b, "</div>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderResultsSection(items []Match) string {
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Výsledky týdne</h2>")
|
||||
for _, m := range items {
|
||||
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #d69e2e;background:#fffbeb;'>")
|
||||
fmt.Fprintf(b, "<div style='font-weight:600;'>%s %s %s</div>", htmlEsc(m.Home), htmlEsc(m.Score), htmlEsc(m.Away))
|
||||
meta := strings.TrimSpace(fmt.Sprintf("%s %s · %s", m.Date, m.Time, m.Competition))
|
||||
fmt.Fprintf(b, "<div style='color:#975a16;font-size:14px;'>%s</div>", htmlEsc(meta))
|
||||
if m.Link != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Zápis</a></div>", m.Link) }
|
||||
fmt.Fprintf(b, "</div>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// small utils
|
||||
|
||||
func asList(v any) []any {
|
||||
if v == nil { return nil }
|
||||
if arr, ok := v.([]any); ok { return arr }
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
if a, ok := m["items"]; ok { return asList(a) }
|
||||
if a, ok := m["data"]; ok { return asList(a) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asMap(v any) map[string]any {
|
||||
if v == nil { return map[string]any{} }
|
||||
if m, ok := v.(map[string]any); ok { return m }
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func str(v any, def string) string {
|
||||
if s, ok := v.(string); ok && s != "" { return s }
|
||||
return def
|
||||
}
|
||||
|
||||
func urlFromSlug(m map[string]any) string {
|
||||
if s, ok := m["slug"].(string); ok && s != "" { return "/news/" + s }
|
||||
return ""
|
||||
}
|
||||
|
||||
func htmlEsc(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/email"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// StartNewsletterScheduler starts a background job that sends automated newsletters.
|
||||
// It can be configured via env NEWSLETTER_INTERVAL_HOURS and is guarded by config.NewsletterEnabled.
|
||||
func StartNewsletterScheduler(db *gorm.DB, emailSvc email.EmailService) {
|
||||
interval := 24 * time.Hour // default daily for demo; real use might be weekly
|
||||
if v := os.Getenv("NEWSLETTER_INTERVAL_HOURS"); v != "" {
|
||||
if h, err := time.ParseDuration(v + "h"); err == nil {
|
||||
interval = h
|
||||
}
|
||||
}
|
||||
|
||||
// Run once on startup only if enabled
|
||||
if config.AppConfig != nil && config.AppConfig.NewsletterEnabled {
|
||||
go runNewsletterCycle(db, emailSvc)
|
||||
} else {
|
||||
log.Printf("[newsletter] scheduler is disabled at startup; waiting for enable toggle")
|
||||
}
|
||||
|
||||
// Periodic ticker
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if config.AppConfig != nil && config.AppConfig.NewsletterEnabled {
|
||||
runNewsletterCycle(db, emailSvc)
|
||||
} else {
|
||||
log.Printf("[newsletter] skipped cycle (disabled)")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runNewsletterCycle(db *gorm.DB, emailSvc email.EmailService) {
|
||||
log.Printf("[newsletter] starting automated newsletter cycle")
|
||||
cacheDir := filepath.Join("cache", "prefetch")
|
||||
|
||||
// Fetch subscribers in batches
|
||||
var subs []models.NewsletterSubscription
|
||||
if err := db.Find(&subs).Error; err != nil {
|
||||
log.Printf("[newsletter] failed to load subscribers: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, s := range subs {
|
||||
if !s.IsActive {
|
||||
continue
|
||||
}
|
||||
// Normalize preferences to new structure
|
||||
ct := make([]string, 0, 4)
|
||||
if v, ok := s.Preferences["blogs"].(bool); ok && v { ct = append(ct, "blogs") }
|
||||
if v, ok := s.Preferences["events"].(bool); ok && v { ct = append(ct, "events") }
|
||||
if v, ok := s.Preferences["matches"].(bool); ok && v { ct = append(ct, "matches") }
|
||||
if v, ok := s.Preferences["scores"].(bool); ok && v { ct = append(ct, "scores") }
|
||||
// Backward-compat: weekly implies blogs; matches implies matches
|
||||
if v, ok := s.Preferences["weekly"].(bool); ok && v && !contains(ct, "blogs") { ct = append(ct, "blogs") }
|
||||
if v, ok := s.Preferences["matches"].(bool); ok && v && !contains(ct, "matches") { ct = append(ct, "matches") }
|
||||
|
||||
// Optional competitions list if stored under key like competitions
|
||||
comps := []string{}
|
||||
if raw, ok := s.Preferences["competitions"].(string); ok && raw != "" {
|
||||
// comma-separated codes
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
if v := strings.TrimSpace(p); v != "" { comps = append(comps, v) }
|
||||
}
|
||||
}
|
||||
|
||||
prefs := NewsletterPrefs{
|
||||
Email: s.Email,
|
||||
ContentTypes: ct,
|
||||
Competitions: comps,
|
||||
Frequency: "daily",
|
||||
}
|
||||
subj, content := BuildNewsletterDigest(cacheDir, prefs)
|
||||
if strings.TrimSpace(content) == "" { continue }
|
||||
|
||||
data := &email.NewsletterData{
|
||||
Subject: subj,
|
||||
Content: content,
|
||||
Recipients: []string{s.Email},
|
||||
}
|
||||
|
||||
if err := emailSvc.SendNewsletter(data); err != nil {
|
||||
log.Printf("[newsletter] failed to send to %s: %v", s.Email, err)
|
||||
}
|
||||
// small sleep to avoid hammering SMTP
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
log.Printf("[newsletter] automated cycle finished")
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SetupService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSetupService(db *gorm.DB) *SetupService {
|
||||
return &SetupService{db: db}
|
||||
}
|
||||
|
||||
// GetSetupStatus returns the current setup status
|
||||
func (s *SetupService) GetSetupStatus() (*models.SetupInfo, error) {
|
||||
var setupInfo models.SetupInfo
|
||||
if err := s.db.FirstOrCreate(&setupInfo, models.SetupInfo{Model: gorm.Model{ID: 1}}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &setupInfo, nil
|
||||
}
|
||||
|
||||
// MarkSMTPConfigured marks the SMTP configuration as completed
|
||||
func (s *SetupService) MarkSMTPConfigured() error {
|
||||
return s.db.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
|
||||
"smtp_configured": true,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// SaveClubInfo saves the club information from FACR
|
||||
func (s *SetupService) SaveClubInfo(clubInfo *models.ClubInfo) error {
|
||||
// Start a transaction
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Update setup info
|
||||
if err := tx.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
|
||||
"club_imported": true,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Save club info
|
||||
clubInfo.SetupInfoID = 1
|
||||
if err := tx.Create(clubInfo).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// CompleteSetup marks the setup as completed
|
||||
func (s *SetupService) CompleteSetup() error {
|
||||
now := time.Now()
|
||||
return s.db.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
|
||||
"status": models.SetupStatusCompleted,
|
||||
"completed_at": now,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// SkipSetup marks the setup as skipped
|
||||
func (s *SetupService) SkipSetup() error {
|
||||
now := time.Now()
|
||||
return s.db.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
|
||||
"status": models.SetupStatusSkipped,
|
||||
"skipped_at": now,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetClubInfo returns the club information if it exists
|
||||
func (s *SetupService) GetClubInfo() (*models.ClubInfo, error) {
|
||||
var clubInfo models.ClubInfo
|
||||
if err := s.db.Where("setup_info_id = ?", 1).First(&clubInfo).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &clubInfo, nil
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user