package services import ( "encoding/json" "fmt" "log" "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) // Determine effective enabling: use settings, or auto-activate if there are subscribers and a match is within 2 hours enabled := settings.EnableMatchReminders 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() if !enabled { subs := na.getSubscribersForType("matches", "") if len(subs) == 0 { return } auto := false for _, match := range matches { matchTime := parseDateTimeISO(match.Date, match.Time) if matchTime.IsZero() || matchTime.Before(now) { continue } if matchTime.Sub(now).Hours() <= 2 { auto = true break } } if !auto { return } // Auto mode: restrict reminder window to 2 hours before kickoff leadHours = 2 enabled = true } for _, match := range matches { matchTime := parseDateTimeISO(match.Date, match.Time) if matchTime.IsZero() || matchTime.Before(now) { continue } hoursUntil := matchTime.Sub(now).Hours() // Check for lead-hour reminder (48h normally, 2h in auto mode) 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) // Determine effective enabling. If disabled, auto-activate when there are subscribers and a recent result exists. enabled := settings.EnableResults // 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 bypassQuiet := false if !enabled { subs := na.getSubscribersForType("scores", "") if len(subs) == 0 { return } auto := false for _, match := range matches { if match.Score == "" || !strings.Contains(match.Score, ":") { continue } matchTime := parseDateTimeISO(match.Date, match.Time) if matchTime.IsZero() || matchTime.After(now) { continue } if now.Sub(matchTime) <= lookback { auto = true break } } if !auto { return } // Auto mode: send immediately when we have a result, ignoring quiet hours bypassQuiet = true enabled = true } // Respect quiet hours only when explicitly enabled in settings (not in auto mode) if !bypassQuiet { currentHour := time.Now().Hour() quietStart := settings.NewsletterQuietStart quietEnd := settings.NewsletterQuietEnd // Consider quiet hours configured when both bounds are within 0..23 and not equal if quietStart >= 0 && quietStart <= 23 && quietEnd >= 0 && quietEnd <= 23 && quietStart != quietEnd { inQuiet := false if quietStart < quietEnd { // Same-day interval, e.g., 08:00–22:00 => quiet when between start and end inQuiet = currentHour >= quietStart && currentHour < quietEnd } else { // Cross-midnight interval, e.g., 22:00–08:00 => quiet when hour >= start OR hour < end inQuiet = currentHour >= quietStart || currentHour < quietEnd } if inQuiet { log.Printf("[newsletter-automation] In quiet hours (%02d:00-%02d:00), skipping result notifications", quietStart, quietEnd) return } } } 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 } func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string { // Short description: prefer excerpt; otherwise derive from content desc := strings.TrimSpace(article.Excerpt) if desc == "" { plain := utils.SanitizeString(article.Content) if len(plain) > 260 { cut := 240 if cut < len(plain) { for cut < len(plain) && plain[cut] != ' ' { cut++ } } if cut > len(plain) { cut = len(plain) } plain = strings.TrimSpace(plain[:cut]) + "…" } desc = plain } // Category badge (if available) cat := strings.TrimSpace(article.CategoryName) var catHTML string if cat != "" { catHTML = fmt.Sprintf(`
%s
`, htmlEsc(cat)) } // Cover image (optional) var imgHTML string if strings.TrimSpace(article.ImageURL) != "" { imgHTML = fmt.Sprintf(`
cover
`, htmlEsc(article.ImageURL)) } html := fmt.Sprintf(`

Nový článek na webu

%s

%s

%s

%s

Číst článek
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL) 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(`

%s

%s vs %s

Datum: %s

Čas: %s

Soutěž: %s

`, 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(`

Výsledek zápasu

%s %s %s

Datum: %s

Soutěž: %s

`, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition)) return html }