This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+263
View File
@@ -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
}
+220
View File
@@ -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
}
+191
View File
@@ -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(&currentUsages)
// 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)
}
+267
View File
@@ -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
}
+547
View File
@@ -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(&notif)
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(&notif)
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(&notif)
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
}
+335
View File
@@ -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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
return r.Replace(s)
}
+114
View File
@@ -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
+96
View File
@@ -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
}
+455
View File
@@ -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
}