mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
563 lines
17 KiB
Go
563 lines
17 KiB
Go
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)
|
|
|
|
if !settings.EnableMatchReminders {
|
|
return
|
|
}
|
|
|
|
leadHours := settings.NewsletterReminderLeadHours
|
|
if leadHours <= 0 {
|
|
leadHours = 48 // Default 2 days
|
|
}
|
|
|
|
// Load match data from cache
|
|
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
|
|
matches := facrAllMatches(facr)
|
|
|
|
now := time.Now()
|
|
|
|
for _, match := range matches {
|
|
matchTime := parseDateTimeISO(match.Date, match.Time)
|
|
if matchTime.IsZero() || matchTime.Before(now) {
|
|
continue
|
|
}
|
|
|
|
hoursUntil := matchTime.Sub(now).Hours()
|
|
|
|
// Check for 48h reminder
|
|
if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) {
|
|
na.sendMatchReminder(match, "reminder_48h", leadHours)
|
|
}
|
|
|
|
// Check for day-of reminder (match starts in 0-6 hours)
|
|
if hoursUntil <= 6 && hoursUntil > 0 {
|
|
na.sendMatchReminder(match, "reminder_day", 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, hoursBeforeText int) {
|
|
// Check if already sent
|
|
var existing models.MatchNotification
|
|
matchKey := fmt.Sprintf("%s-%s-%s", match.Date, match.Home, match.Away)
|
|
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil {
|
|
return
|
|
}
|
|
|
|
// Get subscribers interested in matches and this competition
|
|
subs := na.getSubscribersForType("matches", match.Competition)
|
|
if len(subs) == 0 {
|
|
return
|
|
}
|
|
|
|
// Build email content
|
|
var subject string
|
|
if notifType == "reminder_48h" {
|
|
subject = fmt.Sprintf("Nadcházející zápas za %d hodin: %s vs %s", hoursBeforeText, match.Home, match.Away)
|
|
} else {
|
|
subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away)
|
|
}
|
|
|
|
html := na.buildMatchReminderHTML(match, notifType)
|
|
|
|
recipients := make([]string, 0, len(subs))
|
|
for _, sub := range subs {
|
|
recipients = append(recipients, sub.Email)
|
|
}
|
|
|
|
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder")
|
|
if err != nil {
|
|
logger.Error("[newsletter-automation] Failed to send match reminder: %v", err)
|
|
return
|
|
}
|
|
|
|
// Record notification
|
|
notif := models.MatchNotification{
|
|
MatchID: matchKey,
|
|
NotificationType: notifType,
|
|
SentAt: time.Now(),
|
|
RecipientsCount: len(recipients),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
na.db.Create(¬if)
|
|
|
|
log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients))
|
|
}
|
|
|
|
func (na *NewsletterAutomation) checkFinishedMatches() {
|
|
var settings models.Settings
|
|
na.db.First(&settings)
|
|
|
|
if !settings.EnableResults {
|
|
return
|
|
}
|
|
|
|
// Check quiet hours
|
|
currentHour := time.Now().Hour()
|
|
quietStart := settings.NewsletterQuietStart
|
|
quietEnd := settings.NewsletterQuietEnd
|
|
|
|
if quietStart > 0 && quietEnd > 0 {
|
|
if quietStart < quietEnd {
|
|
// e.g., 22:00 - 08:00
|
|
if currentHour >= quietStart || currentHour < quietEnd {
|
|
log.Printf("[newsletter-automation] In quiet hours, skipping result notifications")
|
|
return
|
|
}
|
|
} else {
|
|
// e.g., 08:00 - 22:00 (inverted, send only during these hours)
|
|
if currentHour < quietStart && currentHour >= quietEnd {
|
|
log.Printf("[newsletter-automation] Outside active hours, skipping result notifications")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load match data
|
|
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
|
|
matches := facrAllMatches(facr)
|
|
|
|
now := time.Now()
|
|
lookback := 6 * time.Hour // Check matches finished in last 6 hours
|
|
|
|
for _, match := range matches {
|
|
if match.Score == "" || !strings.Contains(match.Score, ":") {
|
|
continue // No score yet
|
|
}
|
|
|
|
matchTime := parseDateTimeISO(match.Date, match.Time)
|
|
if matchTime.IsZero() || matchTime.After(now) {
|
|
continue
|
|
}
|
|
|
|
// Check if match finished recently
|
|
timeSinceMatch := now.Sub(matchTime)
|
|
if timeSinceMatch > lookback {
|
|
continue
|
|
}
|
|
|
|
na.sendMatchResult(match)
|
|
}
|
|
}
|
|
|
|
func (na *NewsletterAutomation) sendMatchResult(match Match) {
|
|
// Check if already sent
|
|
matchKey := fmt.Sprintf("%s-%s-%s", match.Date, match.Home, match.Away)
|
|
var existing models.MatchNotification
|
|
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil {
|
|
return
|
|
}
|
|
|
|
// Get subscribers interested in results
|
|
subs := na.getSubscribersForType("scores", match.Competition)
|
|
if len(subs) == 0 {
|
|
return
|
|
}
|
|
|
|
subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away)
|
|
html := na.buildMatchResultHTML(match)
|
|
|
|
recipients := make([]string, 0, len(subs))
|
|
for _, sub := range subs {
|
|
recipients = append(recipients, sub.Email)
|
|
}
|
|
|
|
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result")
|
|
if err != nil {
|
|
logger.Error("[newsletter-automation] Failed to send match result: %v", err)
|
|
return
|
|
}
|
|
|
|
// Record notification
|
|
notif := models.MatchNotification{
|
|
MatchID: matchKey,
|
|
NotificationType: "result",
|
|
SentAt: time.Now(),
|
|
RecipientsCount: len(recipients),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
na.db.Create(¬if)
|
|
|
|
log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients))
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (na *NewsletterAutomation) isEnabled() bool {
|
|
if config.AppConfig == nil {
|
|
return false
|
|
}
|
|
return config.AppConfig.NewsletterEnabled
|
|
}
|
|
|
|
func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription {
|
|
var subs []models.NewsletterSubscription
|
|
na.db.Where("is_active = ?", true).Find(&subs)
|
|
|
|
filtered := make([]models.NewsletterSubscription, 0)
|
|
for _, sub := range subs {
|
|
// Check if subscriber wants this content type
|
|
if val, ok := sub.Preferences[contentType].(bool); ok && val {
|
|
// If category filtering is needed and specified
|
|
if category != "" {
|
|
// Check if subscriber has category preferences
|
|
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
|
|
categoryList := strings.Split(cats, ",")
|
|
found := false
|
|
for _, cat := range categoryList {
|
|
if strings.EqualFold(strings.TrimSpace(cat), category) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
filtered = append(filtered, sub)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscription) NewsletterPrefs {
|
|
prefs := NewsletterPrefs{
|
|
Email: sub.Email,
|
|
ContentTypes: []string{},
|
|
Competitions: []string{},
|
|
Frequency: "daily",
|
|
}
|
|
|
|
// Parse content types
|
|
if v, ok := sub.Preferences["blogs"].(bool); ok && v {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
|
|
}
|
|
if v, ok := sub.Preferences["events"].(bool); ok && v {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "events")
|
|
}
|
|
if v, ok := sub.Preferences["matches"].(bool); ok && v {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "matches")
|
|
}
|
|
if v, ok := sub.Preferences["scores"].(bool); ok && v {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
|
|
}
|
|
|
|
// Parse categories/competitions
|
|
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
|
|
for _, c := range strings.Split(cats, ",") {
|
|
if v := strings.TrimSpace(c); v != "" {
|
|
prefs.Competitions = append(prefs.Competitions, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
return prefs
|
|
}
|
|
|
|
func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string, subject, htmlContent, newsletterType string) error {
|
|
data := &email.NewsletterData{
|
|
Subject: subject,
|
|
Content: htmlContent,
|
|
Recipients: recipients,
|
|
}
|
|
|
|
err := na.emailSvc.SendNewsletter(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Log sent newsletter
|
|
contentIDsJSON, _ := json.Marshal([]string{})
|
|
logEntry := models.NewsletterSentLog{
|
|
NewsletterType: newsletterType,
|
|
Subject: subject,
|
|
ContentIDs: string(contentIDsJSON),
|
|
RecipientsCount: len(recipients),
|
|
SentAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
na.db.Create(&logEntry)
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|