Files
MyClub/internal/services/newsletter_automation.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

626 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&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 (with minute precision to prevent multiple sends)
currentMinute := now.Minute()
if currentDay != targetDay || currentHour != targetHour || currentMinute > 5 {
// Only run in the first 5 minutes of the target hour to avoid repeats
return
}
// Check if already sent today (using database for persistence)
var todaySent models.NewsletterSentLog
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if err := na.db.Where("newsletter_type = ? AND sent_at >= ?", "weekly", todayStart).First(&todaySent).Error; err == nil {
log.Printf("[newsletter-automation] Weekly digest already sent today at %s", todaySent.SentAt.Format("15:04:05"))
return
}
// Also check in-memory as backup
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(&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)
// 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:0022:00 => quiet when between start and end
inQuiet = currentHour >= quietStart && currentHour < quietEnd
} else {
// Cross-midnight interval, e.g., 22:0008: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(&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
}
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(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat))
}
// Cover image (optional)
var imgHTML string
if strings.TrimSpace(article.ImageURL) != "" {
imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL))
}
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 12px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 18px; background: #f8fafc; margin: 16px 0; border-radius:6px;">
%s
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size:22px;">%s</h3>
%s
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 12px 0;">%s</p>
<a href="%s" style="display: inline-block; padding: 12px 20px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
</div>
</div>
`, 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(`
<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
}