mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
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:
@@ -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(¬if)
|
||||
|
||||
|
||||
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(¬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
|
||||
|
||||
// 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: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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(¬if)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ func fetchZonerama(link string) error {
|
||||
}
|
||||
// Profile fetch - gets album metadata only (no photos)
|
||||
albumLimit := envInt("ZONERAMA_ALBUM_LIMIT", 10) // Fetch up to 10 albums metadata
|
||||
apiBase := "https://zonerama.tdvorak.dev/zonerama?link=" + url.QueryEscape(strings.TrimSpace(link)) + "&album_limit=" + strconv.Itoa(albumLimit) + "&photo_limit=0"
|
||||
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
|
||||
apiBase := fmt.Sprintf("%s/zonerama?link=%s&album_limit=%d&photo_limit=0", base, url.QueryEscape(strings.TrimSpace(link)), albumLimit)
|
||||
log.Printf("[prefetch] Fetching Zonerama profile: %s (album_limit=%d, no photos)", apiBase, albumLimit)
|
||||
|
||||
// Increase timeout to 60s since the API can take longer to fetch
|
||||
@@ -223,8 +224,9 @@ func fetchZoneramaAlbums(albums []struct {
|
||||
}
|
||||
|
||||
// Fetch album with photos
|
||||
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
|
||||
url.QueryEscape(album.URL), photoLimit)
|
||||
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
|
||||
apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
|
||||
base, url.QueryEscape(album.URL), photoLimit)
|
||||
|
||||
log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"fotbal-club/pkg/email"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SweepstakesService encapsulates business logic for sweepstakes
|
||||
@@ -83,18 +83,30 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
}
|
||||
// Determine number of winners
|
||||
nWinners := 0
|
||||
for _, p := range prizes { nWinners += max(0, p.Quantity) }
|
||||
for _, p := range prizes {
|
||||
nWinners += max(0, p.Quantity)
|
||||
}
|
||||
if nWinners == 0 {
|
||||
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes }
|
||||
if cur.TotalPrizes > 0 {
|
||||
nWinners = cur.TotalPrizes
|
||||
}
|
||||
}
|
||||
// Cap winners to a safe maximum
|
||||
if nWinners > 100 { nWinners = 100 }
|
||||
if nWinners > len(entries) { nWinners = len(entries) }
|
||||
if nWinners > 100 {
|
||||
nWinners = 100
|
||||
}
|
||||
if nWinners > len(entries) {
|
||||
nWinners = len(entries)
|
||||
}
|
||||
|
||||
// Build seed
|
||||
effSeed := strings.TrimSpace(seed)
|
||||
if effSeed == "" { effSeed = strings.TrimSpace(cur.DrawSeed) }
|
||||
if effSeed == "" { effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano()) }
|
||||
if effSeed == "" {
|
||||
effSeed = strings.TrimSpace(cur.DrawSeed)
|
||||
}
|
||||
if effSeed == "" {
|
||||
effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano())
|
||||
}
|
||||
// Deterministic RNG from SHA-256
|
||||
h := sha256.Sum256([]byte(effSeed))
|
||||
base := binary.LittleEndian.Uint64(h[:8])
|
||||
@@ -125,18 +137,27 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
for j := 0; j < q && pos < len(idx); j++ {
|
||||
cand := entries[idx[pos]]
|
||||
pos++
|
||||
if picked[cand.UserID] { j--; continue }
|
||||
if picked[cand.UserID] {
|
||||
j--
|
||||
continue
|
||||
}
|
||||
picked[cand.UserID] = true
|
||||
assign(cand.UserID, cand.ID, &prizes[i])
|
||||
if len(winners) >= nWinners { break }
|
||||
if len(winners) >= nWinners {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(winners) >= nWinners {
|
||||
break
|
||||
}
|
||||
if len(winners) >= nWinners { break }
|
||||
}
|
||||
// If still need more (when TotalPrizes used)
|
||||
for len(winners) < nWinners && pos < len(idx) {
|
||||
cand := entries[idx[pos]]
|
||||
pos++
|
||||
if picked[cand.UserID] { continue }
|
||||
if picked[cand.UserID] {
|
||||
continue
|
||||
}
|
||||
picked[cand.UserID] = true
|
||||
assign(cand.UserID, cand.ID, nil)
|
||||
}
|
||||
@@ -151,9 +172,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
vis := cur.EndAt.Add(72 * time.Hour)
|
||||
if err := tx.Model(&models.Sweepstake{}).Where("id = ?", cur.ID).Updates(map[string]interface{}{
|
||||
"winners_selected_at": now,
|
||||
"visibility_until": vis,
|
||||
"draw_seed": effSeed,
|
||||
"status": "finalized",
|
||||
"visibility_until": vis,
|
||||
"draw_seed": effSeed,
|
||||
"status": "finalized",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -163,15 +184,21 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
for _, w := range winners {
|
||||
var user models.User
|
||||
_ = tx.First(&user, w.UserID).Error
|
||||
if strings.TrimSpace(user.Email) == "" { continue }
|
||||
if strings.TrimSpace(user.Email) == "" {
|
||||
continue
|
||||
}
|
||||
// Localize end date to Czech format in Europe/Prague timezone
|
||||
loc, _ := time.LoadLocation("Europe/Prague")
|
||||
endsLocal := cur.EndAt.In(loc)
|
||||
endsAtCz := endsLocal.Format("02. 01. 2006 15:04")
|
||||
_ = s.Email.SendEmail(&email.EmailData{
|
||||
Subject: "Vyhráli jste v soutěži!",
|
||||
To: []string{strings.TrimSpace(user.Email)},
|
||||
Template: "sweepstake_winner_user",
|
||||
Data: map[string]interface{}{
|
||||
"Title": cur.Title,
|
||||
"Title": cur.Title,
|
||||
"PrizeName": w.PrizeName,
|
||||
"EndsAt": cur.EndAt.Format(time.RFC1123),
|
||||
"EndsAt": endsAtCz,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -179,14 +206,16 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
var set models.Settings
|
||||
_ = tx.First(&set).Error
|
||||
adminTo := strings.TrimSpace(set.ContactEmail)
|
||||
if adminTo == "" { adminTo = strings.TrimSpace(set.SMTPFrom) }
|
||||
if adminTo == "" {
|
||||
adminTo = strings.TrimSpace(set.SMTPFrom)
|
||||
}
|
||||
if adminTo != "" {
|
||||
_ = s.Email.SendEmail(&email.EmailData{
|
||||
Subject: "Soutěž – vybraní výherci",
|
||||
To: []string{adminTo},
|
||||
Template: "sweepstake_winner_admin",
|
||||
Data: map[string]interface{}{
|
||||
"Title": cur.Title,
|
||||
"Title": cur.Title,
|
||||
"WinnersCount": len(winners),
|
||||
},
|
||||
})
|
||||
@@ -196,4 +225,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
})
|
||||
}
|
||||
|
||||
func max(a, b int) int { if a > b { return a } ; return b }
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user