dev day #100 - WE ARE FUCKING DONE, hotfixes incoming but we did it in 100 days, lets fucking go guys, anyone reading this...i love you

This commit is contained in:
Tomas Dvorak
2025-11-22 21:30:10 +01:00
parent f5b6f83974
commit aa036b6550
47 changed files with 3607 additions and 2177 deletions
+187 -134
View File
@@ -19,10 +19,10 @@ import (
// NewsletterAutomation handles all automated newsletter sending
type NewsletterAutomation struct {
db *gorm.DB
emailSvc email.EmailService
cacheDir string
lastWeekly time.Time
db *gorm.DB
emailSvc email.EmailService
cacheDir string
lastWeekly time.Time
lastMatchCheck time.Time
}
@@ -38,12 +38,12 @@ func NewNewsletterAutomation(db *gorm.DB, emailSvc email.EmailService) *Newslett
// 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() {
@@ -59,18 +59,18 @@ func (na *NewsletterAutomation) RunCycle() {
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")
}
@@ -79,40 +79,40 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
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,
@@ -121,7 +121,7 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
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
}
@@ -129,11 +129,11 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
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 == "" {
@@ -143,47 +143,47 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
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")
}
@@ -191,35 +191,57 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
func (na *NewsletterAutomation) checkUpcomingMatches() {
var settings models.Settings
na.db.First(&settings)
if !settings.EnableMatchReminders {
return
}
// 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 48h reminder
// 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)
@@ -234,13 +256,13 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
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" {
@@ -248,20 +270,20 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
} 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,
@@ -271,62 +293,91 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
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
// 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
}
} 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")
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
}
}
}
// 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)
}
}
@@ -338,27 +389,27 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) {
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,
@@ -368,7 +419,7 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) {
CreatedAt: time.Now(),
}
na.db.Create(&notif)
log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients))
}
@@ -384,7 +435,7 @@ func (na *NewsletterAutomation) isEnabled() bool {
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
@@ -409,7 +460,7 @@ func (na *NewsletterAutomation) getSubscribersForType(contentType, category stri
filtered = append(filtered, sub)
}
}
return filtered
}
@@ -420,7 +471,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
Competitions: []string{},
Frequency: "daily",
}
// Parse content types
if v, ok := sub.Preferences["blogs"].(bool); ok && v {
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
@@ -434,7 +485,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
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, ",") {
@@ -443,7 +494,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
}
}
}
return prefs
}
@@ -453,12 +504,12 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
Content: htmlContent,
Recipients: recipients,
}
err := na.emailSvc.SendNewsletter(data)
if err != nil {
return err
}
// Log sent newsletter
contentIDsJSON, _ := json.Marshal([]string{})
logEntry := models.NewsletterSentLog{
@@ -470,42 +521,44 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
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
}
// 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))
}
// 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))
}
// 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(`
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;">
@@ -518,18 +571,18 @@ func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Articl
</div>
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL)
return html
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(`
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>
@@ -541,7 +594,7 @@ func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType st
</div>
</div>
`, intro, htmlEsc(match.Home), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Time), htmlEsc(match.Competition))
return html
}
@@ -557,6 +610,6 @@ func (na *NewsletterAutomation) buildMatchResultHTML(match Match) string {
</div>
</div>
`, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition))
return html
}