mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
de day #74
This commit is contained in:
@@ -129,6 +129,9 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-subscribe newly registered fans to the newsletter
|
||||
_ = models.SubscribeToNewsletter(ac.DB, user.Email)
|
||||
|
||||
// For first user, ensure setup info exists
|
||||
if isFirstUser {
|
||||
_, err := ac.setupService.GetSetupStatus()
|
||||
|
||||
+1349
-2014
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gopkg.in/mail.v2"
|
||||
)
|
||||
|
||||
type ContactController struct {
|
||||
@@ -27,7 +28,61 @@ type ContactController struct {
|
||||
emailService email.EmailService
|
||||
}
|
||||
|
||||
// GetNewsletterTokenForUser returns a short-lived newsletter preferences token for the authenticated user's email
|
||||
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
UseTLS bool `json:"use_tls"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(input.Host) == "" || input.Port <= 0 || strings.TrimSpace(input.From) == "" || strings.TrimSpace(input.To) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "host, port, from and to are required"})
|
||||
return
|
||||
}
|
||||
|
||||
d := mail.NewDialer(strings.TrimSpace(input.Host), input.Port, strings.TrimSpace(input.Username), input.Password)
|
||||
d.SSL = input.UseTLS
|
||||
d.Timeout = 30 * time.Second
|
||||
|
||||
subj := strings.TrimSpace(input.Subject)
|
||||
if subj == "" {
|
||||
subj = "SMTP Test"
|
||||
}
|
||||
body := strings.TrimSpace(input.Body)
|
||||
if body == "" {
|
||||
body = "<p>Toto je testovací e‑mail SMTP z administrace.</p>"
|
||||
}
|
||||
|
||||
m := mail.NewMessage()
|
||||
from := strings.TrimSpace(input.From)
|
||||
to := strings.TrimSpace(input.To)
|
||||
m.SetHeader("From", from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subj)
|
||||
m.SetDateHeader("Date", time.Now())
|
||||
m.SetBody("text/plain", "SMTP test email")
|
||||
m.AddAlternative("text/html", body)
|
||||
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Test email sent"})
|
||||
}
|
||||
|
||||
// GET /api/v1/newsletter/token/me (auth required)
|
||||
func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
|
||||
u, ok := c.Get("user")
|
||||
@@ -43,24 +98,27 @@ func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a 24h token for managing newsletter preferences
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.Where("email = ?", email).First(&sub).Error; err != nil {
|
||||
_ = cc.DB.Create(&models.NewsletterSubscription{Email: email, IsActive: true}).Error
|
||||
} else if !sub.IsActive {
|
||||
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", email).Update("is_active", true).Error
|
||||
}
|
||||
|
||||
token, err := utils.GenerateSubscriberToken(email, 60*24)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
|
||||
// SendNewsletterDigest builds and sends a digest newsletter based on a template type (admin only)
|
||||
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
|
||||
// POST /api/v1/admin/newsletter/send-digest
|
||||
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Competitions string `json:"competitions"`
|
||||
@@ -69,7 +127,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
|
||||
return
|
||||
}
|
||||
|
||||
t := strings.ToLower(strings.TrimSpace(input.Type))
|
||||
allowed := map[string]bool{"blogs": true, "events": true, "matches": true, "scores": true, "weekly": true}
|
||||
if !allowed[t] {
|
||||
@@ -77,7 +134,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch active subscribers
|
||||
var subscribers []models.NewsletterSubscription
|
||||
if err := cc.DB.Where("is_active = ?", true).Find(&subscribers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"})
|
||||
@@ -88,13 +144,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build digest content once based on selected type
|
||||
prefs := services.NewsletterPrefs{
|
||||
Email: "digest@local",
|
||||
ContentTypes: []string{},
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
prefs := services.NewsletterPrefs{Email: "digest@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||
if t == "weekly" {
|
||||
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
|
||||
prefs.Frequency = "weekly"
|
||||
@@ -103,9 +153,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
}
|
||||
if strings.TrimSpace(input.Competitions) != "" {
|
||||
for _, p := range strings.Split(input.Competitions, ",") {
|
||||
if v := strings.TrimSpace(p); v != "" {
|
||||
prefs.Competitions = append(prefs.Competitions, v)
|
||||
}
|
||||
if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) }
|
||||
}
|
||||
}
|
||||
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||
@@ -113,227 +161,103 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No content for selected digest"})
|
||||
return
|
||||
}
|
||||
|
||||
// Recipients list
|
||||
recipients := make([]string, 0, len(subscribers))
|
||||
for _, s := range subscribers {
|
||||
if s.Email != "" {
|
||||
recipients = append(recipients, s.Email)
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"})
|
||||
return
|
||||
}
|
||||
|
||||
if subj == "" {
|
||||
subj = strings.Title(t) + " digest"
|
||||
}
|
||||
|
||||
for _, s := range subscribers { if s.Email != "" { recipients = append(recipients, s.Email) } }
|
||||
if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"}); return }
|
||||
if subj == "" { subj = strings.Title(t) + " digest" }
|
||||
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send digest newsletter: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Digest newsletter sent", "recipients": len(recipients), "type": t})
|
||||
}
|
||||
|
||||
// UpdateNewsletterAutomation toggles the automated newsletter scheduler at runtime (non-persistent)
|
||||
// PATCH /api/v1/admin/newsletter/enable { enabled: boolean }
|
||||
// PATCH /api/v1/admin/newsletter/enable
|
||||
func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
var input struct { Enabled bool `json:"enabled"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
// Persist to Settings (singleton row)
|
||||
var s models.Settings
|
||||
_ = cc.DB.First(&s).Error // ignore not found
|
||||
if s.ID == 0 {
|
||||
s = models.Settings{}
|
||||
}
|
||||
_ = cc.DB.First(&s).Error
|
||||
if s.ID == 0 { s = models.Settings{} }
|
||||
s.NewsletterEnabled = input.Enabled
|
||||
if s.ID == 0 {
|
||||
if err := cc.DB.Create(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
||||
return
|
||||
}
|
||||
} else if err := cc.DB.Save(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
||||
return
|
||||
}
|
||||
// Flip the in-memory config flag; effective immediately for next tick
|
||||
if config.AppConfig != nil {
|
||||
config.AppConfig.NewsletterEnabled = input.Enabled
|
||||
}
|
||||
if err := cc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
|
||||
} else if err := cc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
|
||||
if config.AppConfig != nil { config.AppConfig.NewsletterEnabled = input.Enabled }
|
||||
c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled})
|
||||
}
|
||||
|
||||
// GetNewsletterStatus returns basic scheduling/status info for newsletters (admin only)
|
||||
// @Summary Newsletter status
|
||||
// @Description Returns subscriber stats and next approximate run time based on interval
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/status [get]
|
||||
// GET /api/v1/admin/newsletter/status
|
||||
func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
var active int64
|
||||
var total, active int64
|
||||
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
||||
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
||||
|
||||
var subs []models.NewsletterSubscription
|
||||
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
||||
sample := make([]string, 0, len(subs))
|
||||
for _, s := range subs {
|
||||
if s.Email != "" {
|
||||
sample = append(sample, s.Email)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range subs { if s.Email != "" { sample = append(sample, s.Email) } }
|
||||
interval := 24 * time.Hour
|
||||
if v := strings.TrimSpace(os.Getenv("NEWSLETTER_INTERVAL_HOURS")); v != "" {
|
||||
if d, err := time.ParseDuration(v + "h"); err == nil {
|
||||
interval = d
|
||||
}
|
||||
if d, err := time.ParseDuration(v + "h"); err == nil { interval = d }
|
||||
}
|
||||
next := time.Now().Add(interval)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_subscribers": total,
|
||||
"active_subscribers": active,
|
||||
"sample_recipients": sample,
|
||||
"interval_minutes": int(interval.Minutes()),
|
||||
"next_approximate": next,
|
||||
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"total_subscribers": total, "active_subscribers": active, "sample_recipients": sample, "interval_minutes": int(interval.Minutes()), "next_approximate": next, "newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled})
|
||||
}
|
||||
|
||||
// PreviewNewsletter builds a digest preview (subject + html) for admin without sending
|
||||
// @Summary Preview newsletter digest (admin)
|
||||
// @Description Returns subject and HTML for a digest newsletter using current cache and optional preferences
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param prefs body map[string]interface{} false "Optional { preferences: { blogs, matches, events, scores, competitions } }"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/preview [post]
|
||||
// POST /api/v1/admin/newsletter/preview
|
||||
func (cc *ContactController) PreviewNewsletter(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Preferences map[string]interface{} `json:"preferences"`
|
||||
}
|
||||
var input struct { Preferences map[string]interface{} `json:"preferences"` }
|
||||
_ = c.ShouldBindJSON(&input)
|
||||
|
||||
// Normalize preferences to NewsletterPrefs
|
||||
prefs := services.NewsletterPrefs{
|
||||
Email: "preview@local",
|
||||
ContentTypes: []string{},
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
prefs := services.NewsletterPrefs{Email: "preview@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||
if m := input.Preferences; m != nil {
|
||||
if b, ok := m["blogs"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
|
||||
}
|
||||
if b, ok := m["events"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "events")
|
||||
}
|
||||
if b, ok := m["matches"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "matches")
|
||||
}
|
||||
if b, ok := m["scores"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
|
||||
}
|
||||
if b, ok := m["blogs"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "blogs") }
|
||||
if b, ok := m["events"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "events") }
|
||||
if b, ok := m["matches"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "matches") }
|
||||
if b, ok := m["scores"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "scores") }
|
||||
if cs, ok := m["competitions"].(string); ok && strings.TrimSpace(cs) != "" {
|
||||
parts := strings.Split(cs, ",")
|
||||
for _, p := range parts {
|
||||
if v := strings.TrimSpace(p); v != "" {
|
||||
prefs.Competitions = append(prefs.Competitions, v)
|
||||
}
|
||||
}
|
||||
for _, p := range strings.Split(cs, ",") { if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) } }
|
||||
}
|
||||
}
|
||||
|
||||
cacheDir := "cache/prefetch"
|
||||
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
|
||||
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||
c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html})
|
||||
}
|
||||
|
||||
// GetNewsletterPreferencesByToken returns subscriber preferences using a token (no auth required)
|
||||
// GET /api/v1/newsletter/preferences?token=...
|
||||
// GET /api/v1/newsletter/preferences
|
||||
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}); return }
|
||||
emailStr, err := utils.ParseSubscriberToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"email": sub.Email,
|
||||
"is_active": sub.IsActive,
|
||||
"preferences": sub.Preferences,
|
||||
})
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"email": sub.Email, "is_active": sub.IsActive, "preferences": sub.Preferences})
|
||||
}
|
||||
|
||||
// SaveNewsletterPreferencesByToken saves subscriber preferences using a token (no auth required)
|
||||
// POST /api/v1/newsletter/preferences { token, preferences }
|
||||
// POST /api/v1/newsletter/preferences
|
||||
func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
var input struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Preferences map[string]interface{} `json:"preferences" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct { Token string `json:"token" binding:"required"`; Preferences map[string]interface{} `json:"preferences" binding:"required"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
|
||||
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}); return }
|
||||
jm := datatypes.JSONMap{}
|
||||
for key, raw := range input.Preferences {
|
||||
switch v := raw.(type) {
|
||||
@@ -343,13 +267,7 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
jm[key] = strings.TrimSpace(v)
|
||||
case []interface{}:
|
||||
compiled := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
||||
compiled = append(compiled, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range v { if s, ok := item.(string); ok { if trimmed := strings.TrimSpace(s); trimmed != "" { compiled = append(compiled, trimmed) } } }
|
||||
jm[key] = strings.Join(compiled, ", ")
|
||||
case float64, int, int64:
|
||||
jm[key] = v
|
||||
@@ -360,7 +278,6 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if compVal, ok := jm["competitions"]; ok {
|
||||
if compStr, ok := compVal.(string); ok {
|
||||
comp := strings.TrimSpace(compStr)
|
||||
@@ -374,32 +291,18 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sub.Preferences = jm
|
||||
sub.UpdatedAt = time.Now()
|
||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
|
||||
}
|
||||
|
||||
// UnsubscribeByToken disables newsletter using a token (no auth required)
|
||||
// POST /api/v1/newsletter/unsubscribe-token { token }
|
||||
// POST /api/v1/newsletter/unsubscribe-token
|
||||
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
||||
var input struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
var input struct { Token string `json:"token" binding:"required"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
|
||||
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||
if err := cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", emailStr).Update("is_active", false).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
|
||||
return
|
||||
@@ -407,213 +310,67 @@ func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "You have been unsubscribed"})
|
||||
}
|
||||
|
||||
// DeleteNewsletterSubscriber deletes a newsletter subscriber (admin only)
|
||||
// @Summary Delete newsletter subscriber
|
||||
// @Description Deletes a newsletter subscriber by ID (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Produce json
|
||||
// @Param id path int true "Subscriber ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/subscribers/{id} [delete]
|
||||
// DELETE /api/v1/admin/newsletter/subscribers/:id
|
||||
func (cc *ContactController) DeleteNewsletterSubscriber(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||
result := cc.DB.Delete(&models.NewsletterSubscription{}, id)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
||||
return
|
||||
}
|
||||
if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"}); return }
|
||||
if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Subscriber deleted successfully"})
|
||||
}
|
||||
|
||||
// UpdateNewsletterSubscriberStatus toggles a subscriber's active status (admin only)
|
||||
// @Summary Update newsletter subscriber status
|
||||
// @Description Updates the is_active status of a newsletter subscriber (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Subscriber ID"
|
||||
// @Param input body map[string]bool true "{ is_active: boolean }"
|
||||
// @Success 200 {object} models.NewsletterSubscription
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/subscribers/{id}/status [patch]
|
||||
// PATCH /api/v1/admin/newsletter/subscribers/:id/status
|
||||
func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
IsActive bool `json:"is_active" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||
var input struct { IsActive bool `json:"is_active" binding:"required"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.First(&sub, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"}) }
|
||||
return
|
||||
}
|
||||
|
||||
sub.IsActive = input.IsActive
|
||||
sub.UpdatedAt = time.Now()
|
||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"}); return }
|
||||
c.JSON(http.StatusOK, sub)
|
||||
}
|
||||
|
||||
// UpdateNewsletterSubscriberPreferences updates subscriber preferences (admin only)
|
||||
// @Summary Update newsletter subscriber preferences
|
||||
// @Description Updates the preferences JSON for a subscriber (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Subscriber ID"
|
||||
// @Param input body map[string]bool true "Preferences map"
|
||||
// @Success 200 {object} models.NewsletterSubscription
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/subscribers/{id}/preferences [patch]
|
||||
// PATCH /api/v1/admin/newsletter/subscribers/:id/preferences
|
||||
func (cc *ContactController) UpdateNewsletterSubscriberPreferences(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||
var prefs map[string]bool
|
||||
if err := c.ShouldBindJSON(&prefs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&prefs); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.First(&sub, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"}) }
|
||||
return
|
||||
}
|
||||
|
||||
// convert map[string]bool to datatypes.JSONMap
|
||||
jm := datatypes.JSONMap{}
|
||||
for k, v := range prefs {
|
||||
jm[k] = v
|
||||
}
|
||||
for k, v := range prefs { jm[k] = v }
|
||||
sub.Preferences = jm
|
||||
sub.UpdatedAt = time.Now()
|
||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"}); return }
|
||||
c.JSON(http.StatusOK, sub)
|
||||
}
|
||||
|
||||
// SendNewsletterTest sends a test newsletter email to a single recipient (admin only)
|
||||
// @Summary Send test newsletter email
|
||||
// @Description Sends a test newsletter email to a single recipient (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param input body map[string]string false "Optional {email} to send test to"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/test [post]
|
||||
// POST /api/v1/admin/newsletter/test
|
||||
func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Emails []string `json:"emails"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
var input struct { Email string `json:"email"`; Emails []string `json:"emails"`; Type string `json:"type"` }
|
||||
_ = c.ShouldBindJSON(&input)
|
||||
|
||||
// Resolve recipients (emails > email > admin)
|
||||
recipients := make([]string, 0)
|
||||
if len(input.Emails) > 0 {
|
||||
for _, e := range input.Emails {
|
||||
if v := strings.TrimSpace(e); v != "" {
|
||||
recipients = append(recipients, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
if v := strings.TrimSpace(input.Email); v != "" {
|
||||
recipients = append(recipients, v)
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" {
|
||||
recipients = append(recipients, v)
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"})
|
||||
return
|
||||
}
|
||||
|
||||
t := strings.ToLower(strings.TrimSpace(input.Type))
|
||||
if t == "" {
|
||||
t = "newsletter"
|
||||
}
|
||||
|
||||
if len(input.Emails) > 0 { for _, e := range input.Emails { if v := strings.TrimSpace(e); v != "" { recipients = append(recipients, v) } } }
|
||||
if len(recipients) == 0 { if v := strings.TrimSpace(input.Email); v != "" { recipients = append(recipients, v) } }
|
||||
if len(recipients) == 0 { if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" { recipients = append(recipients, v) } }
|
||||
if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"}); return }
|
||||
t := strings.ToLower(strings.TrimSpace(input.Type)); if t == "" { t = "newsletter" }
|
||||
logger.Info("[SendNewsletterTest] type=%s recipients=%v", t, recipients)
|
||||
|
||||
switch t {
|
||||
case "newsletter":
|
||||
testHTML := `<p>Toto je testovací newsletter z Fotbal Club. Nastavení SMTP funguje.</p>`
|
||||
@@ -621,155 +378,34 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
|
||||
logger.Debug("[SendNewsletterTest] invoking emailService.SendNewsletter for %d recipient(s)", len(recipients))
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send test newsletter: %v", err)
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter", "details": err.Error()})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter"})
|
||||
}
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter", "details": err.Error()}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter"}) }
|
||||
return
|
||||
}
|
||||
case "welcome":
|
||||
for _, r := range recipients {
|
||||
w := &email.NewsletterWelcomeData{Email: r, UnsubscribeLink: ""}
|
||||
if err := cc.emailService.SendNewsletterWelcome(w); err != nil {
|
||||
logger.Error("Failed to send welcome test to %s: %v", r, err)
|
||||
}
|
||||
}
|
||||
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcome(&email.NewsletterWelcomeData{Email: r}) }
|
||||
case "welcome_back":
|
||||
for _, r := range recipients {
|
||||
w := &email.NewsletterWelcomeBackData{Email: r}
|
||||
if err := cc.emailService.SendNewsletterWelcomeBack(w); err != nil {
|
||||
logger.Error("Failed to send welcome back test to %s: %v", r, err)
|
||||
}
|
||||
}
|
||||
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcomeBack(&email.NewsletterWelcomeBackData{Email: r}) }
|
||||
case "setup":
|
||||
// Test subscription setup email with token
|
||||
for _, r := range recipients {
|
||||
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
|
||||
if tErr != nil {
|
||||
logger.Error("Failed to generate token for setup test: %v", tErr)
|
||||
continue
|
||||
}
|
||||
if tErr != nil { logger.Error("Failed to generate token for setup test: %v", tErr); continue }
|
||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
|
||||
setupEmail := &email.EmailData{
|
||||
Subject: "Test: Nastavte svůj newsletter",
|
||||
To: []string{r},
|
||||
Template: "newsletter_setup",
|
||||
Data: struct{ SetupURL string }{SetupURL: setupURL},
|
||||
}
|
||||
if err := cc.emailService.SendEmail(setupEmail); err != nil {
|
||||
logger.Error("Failed to send setup test to %s: %v", r, err)
|
||||
}
|
||||
setupEmail := &email.EmailData{Subject: "Test: Nastavte svůj newsletter", To: []string{r}, Template: "newsletter_setup", Data: struct{ SetupURL string }{SetupURL: setupURL}}
|
||||
_ = cc.emailService.SendEmail(setupEmail)
|
||||
}
|
||||
case "match_reminder_48h":
|
||||
// Test 48h match reminder
|
||||
testHTML := `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Připomínáme nadcházející zápas:</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;">FC Test vs SK Example</h3>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> 2025-10-02</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
|
||||
</div>
|
||||
<p style="color: #4a5568; margin-top: 20px;">Zápas začíná za 48 hodin. Nezapomeňte!</p>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Nadcházející zápas za 48 hodin", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send match reminder test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
case "match_reminder_today":
|
||||
// Test day-of match reminder
|
||||
testHTML := `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Zápas je dnes!</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;">FC Test vs SK Example</h3>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> Dnes</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
|
||||
</div>
|
||||
<p style="color: #4a5568; margin-top: 20px;">Přijďte fandit!</p>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Zápas dnes", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send match today test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
case "blog_notification":
|
||||
// Test blog release notification
|
||||
testHTML := `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
|
||||
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">Testovací článek: Zajímavosti ze sezóny</h3>
|
||||
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">Toto je ukázkový výňatek z nového článku na našem webu. Přečtěte si celý příběh a dozvíte se více zajímavostí ze sezóny.</p>
|
||||
<a href="https://example.com/news/test" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
|
||||
</div>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Nový článek - Zajímavosti ze sezóny", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send blog notification test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
case "match_result":
|
||||
// Test match result notification
|
||||
testHTML := `
|
||||
<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;">FC Test <span style="color: #d69e2e;">3:2</span> SK Example</h3>
|
||||
<p style="color: #975a16; margin: 5px 0;"><strong>Datum:</strong> 2025-09-30</p>
|
||||
<p style="color: #975a16; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
||||
<p style="color: #975a16; margin: 10px 0 0 0;">Gratulujeme týmu k vítězství!</p>
|
||||
</div>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Výsledek - FC Test 3:2 SK Example", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send match result test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
// Predefined digest test types mapped to content sections
|
||||
case "blogs", "events", "matches", "scores", "weekly":
|
||||
prefs := services.NewsletterPrefs{
|
||||
Email: recipients[0],
|
||||
ContentTypes: []string{},
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
if t == "weekly" {
|
||||
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
|
||||
prefs.Frequency = "weekly"
|
||||
} else {
|
||||
prefs.ContentTypes = []string{t}
|
||||
}
|
||||
cacheDir := "cache/prefetch"
|
||||
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
|
||||
if subj == "" {
|
||||
subj = "Test digest"
|
||||
}
|
||||
if html == "" {
|
||||
html = "<p>Momentálně žádný obsah pro zvolený typ.</p>"
|
||||
}
|
||||
prefs := services.NewsletterPrefs{Email: recipients[0], ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||
if t == "weekly" { prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}; prefs.Frequency = "weekly" } else { prefs.ContentTypes = []string{t} }
|
||||
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||
if subj == "" { subj = "Test digest" }
|
||||
if html == "" { html = "<p>Momentálně žádný obsah pro zvolený typ.</p>" }
|
||||
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send digest test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"})
|
||||
return
|
||||
}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil { logger.Error("Failed to send digest test: %v", err); c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"}); return }
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown test type"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test email(s) sent", "recipients": recipients, "type": t})
|
||||
}
|
||||
|
||||
@@ -928,7 +564,7 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
|
||||
// Create new subscription. Default: enable everything if preferences omitted
|
||||
prefs := input.Preferences
|
||||
if prefs == nil {
|
||||
prefs = map[string]bool{"weekly": true, "matches": true, "blogs": true, "events": true}
|
||||
prefs = map[string]bool{"weekly": true, "matches": true, "blogs": true, "events": true, "scores": true}
|
||||
}
|
||||
// convert to datatypes.JSONMap
|
||||
jm := datatypes.JSONMap{}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
@@ -233,7 +234,11 @@ func (fc *FilesController) GetFileUsages(c *gin.Context) {
|
||||
|
||||
// ScanAndSyncFiles scans the uploads directory and syncs with database
|
||||
func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
|
||||
uploadsDir := "uploads"
|
||||
// Use configured uploads directory (normalized to absolute in main startup)
|
||||
uploadsDir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(uploadsDir) == "" {
|
||||
uploadsDir = "./uploads"
|
||||
}
|
||||
|
||||
var filesInDB []models.UploadedFile
|
||||
if err := fc.DB.Find(&filesInDB).Error; err != nil {
|
||||
@@ -287,7 +292,13 @@ func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
|
||||
if !existsOriginal && !existsNormalized {
|
||||
// File exists on disk but not in database - add it
|
||||
mimeType := detectMimeType(path)
|
||||
fileURL := "/" + filepath.ToSlash(path)
|
||||
// Compute public URL under /uploads by making path relative to uploadsDir
|
||||
rel, rerr := filepath.Rel(uploadsDir, path)
|
||||
if rerr != nil {
|
||||
// If for some reason file is outside base (symlink), skip
|
||||
return nil
|
||||
}
|
||||
fileURL := "/uploads/" + filepath.ToSlash(rel)
|
||||
|
||||
newFile := models.UploadedFile{
|
||||
Filename: filename,
|
||||
|
||||
@@ -12,8 +12,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type ImageProcessingController struct{}
|
||||
@@ -143,9 +146,20 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Return the new URL
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||
xfl := strings.ToLower(xf)
|
||||
if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") {
|
||||
host = xf
|
||||
}
|
||||
}
|
||||
absolute := scheme + "://" + host + outputPath
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": outputPath,
|
||||
"url": absolute,
|
||||
"format": format,
|
||||
})
|
||||
}
|
||||
@@ -154,8 +168,14 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
|
||||
func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image, string, error) {
|
||||
// Check if it's a local file path
|
||||
if strings.HasPrefix(imageURL, "/uploads/") || strings.HasPrefix(imageURL, "uploads/") {
|
||||
// Local file
|
||||
localPath := filepath.Join(".", imageURL)
|
||||
// Local file under configured uploads dir
|
||||
base := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(base) == "" {
|
||||
base = "./uploads"
|
||||
}
|
||||
rel := strings.TrimPrefix(imageURL, "/")
|
||||
rel = strings.TrimPrefix(rel, "uploads/")
|
||||
localPath := filepath.Join(base, filepath.FromSlash(rel))
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to open local file: %w", err)
|
||||
@@ -169,14 +189,27 @@ func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image,
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// HTTP URL
|
||||
resp, err := http.Get(imageURL)
|
||||
// HTTP URL - use custom client and headers, some CDNs block default Go UA
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
req, err := http.NewRequest("GET", imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
// Set a recognizable UA; align with other proxy endpoints
|
||||
req.Header.Set("User-Agent", "fotbal-club/1.0")
|
||||
req.Header.Set("Accept", "image/*")
|
||||
// Some providers (e.g. Zonerama) may require a referer
|
||||
if strings.Contains(strings.ToLower(imageURL), "zonerama.com") {
|
||||
req.Header.Set("Referer", "https://zonerama.com/")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to fetch image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -190,8 +223,11 @@ func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image,
|
||||
|
||||
// saveProcessedImage saves the processed image and returns the path
|
||||
func (ctrl *ImageProcessingController) saveProcessedImage(img image.Image, format string, quality int) (string, error) {
|
||||
// Create uploads directory if it doesn't exist
|
||||
uploadsDir := "./uploads"
|
||||
// Create uploads directory if it doesn't exist (use configured UploadDir)
|
||||
uploadsDir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(uploadsDir) == "" {
|
||||
uploadsDir = "./uploads"
|
||||
}
|
||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create uploads directory: %w", err)
|
||||
}
|
||||
@@ -297,8 +333,20 @@ func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||
xfl := strings.ToLower(xf)
|
||||
if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") {
|
||||
host = xf
|
||||
}
|
||||
}
|
||||
absolute := scheme + "://" + host + outputPath
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": outputPath,
|
||||
"url": absolute,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -513,10 +513,11 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
{Label: "Prefetch & Cache", Type: models.NavTypeInternal, PageType: "prefetch", DisplayOrder: 22, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Uživatelé", Type: models.NavTypeInternal, PageType: "users", DisplayOrder: 23, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Nastavení", Type: models.NavTypeInternal, PageType: "settings", DisplayOrder: 24, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Zkrácené odkazy", Type: models.NavTypeInternal, PageType: "shortlinks", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
|
||||
|
||||
// Help section
|
||||
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 27, Visible: true, RequiresAdmin: true},
|
||||
}
|
||||
|
||||
// Combine all items
|
||||
|
||||
@@ -15,9 +15,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func uploadsBaseDir() string {
|
||||
dir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "./uploads"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
|
||||
func sanitizeAndWriteLogo(data []byte, outPath string) error {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
@@ -102,7 +112,8 @@ func ensureUniqueFilename(dir, name string) string {
|
||||
|
||||
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
|
||||
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
|
||||
entries, err := os.ReadDir(filepath.Join("uploads", "sponsors"))
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
entries, err := os.ReadDir(sponsorDir)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusOK, []string{})
|
||||
return
|
||||
@@ -125,7 +136,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join("uploads", "sponsors"), 0o755)
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||
|
||||
saved := 0
|
||||
if ctx.Request.MultipartForm != nil {
|
||||
@@ -145,8 +157,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
|
||||
base := name
|
||||
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
|
||||
outName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), base+".png")
|
||||
outPath := filepath.Join("uploads", "sponsors", outName)
|
||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
outPath := filepath.Join(sponsorDir, outName)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, src); err == nil {
|
||||
@@ -154,8 +166,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||
saved++
|
||||
} else {
|
||||
// Fallback: write original bytes with original extension
|
||||
rawName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), name)
|
||||
rawPath := filepath.Join("uploads", "sponsors", rawName)
|
||||
rawName := ensureUniqueFilename(sponsorDir, name)
|
||||
rawPath := filepath.Join(sponsorDir, rawName)
|
||||
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
||||
saved++
|
||||
}
|
||||
@@ -173,7 +185,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
||||
return
|
||||
}
|
||||
p := filepath.Join("uploads", "sponsors", name)
|
||||
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -187,7 +199,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
||||
|
||||
// GetQR returns the current QR image URL if present
|
||||
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
|
||||
path := filepath.Join("uploads", "qr.png")
|
||||
path := filepath.Join(uploadsBaseDir(), "qr.png")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
||||
return
|
||||
@@ -203,8 +215,9 @@ func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
_ = os.MkdirAll("uploads", 0o755)
|
||||
out, err := os.Create(filepath.Join("uploads", "qr.png"))
|
||||
dir := uploadsBaseDir()
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
out, err := os.Create(filepath.Join(dir, "qr.png"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ShortLinkController struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewShortLinkController(db *gorm.DB) *ShortLinkController {
|
||||
return &ShortLinkController{DB: db}
|
||||
}
|
||||
|
||||
func randCode(n int) (string, error) {
|
||||
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func clientIP(c *gin.Context) string {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if xr := c.GetHeader("X-Real-IP"); xr != "" {
|
||||
return xr
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
func hashIPShort(ip string) string {
|
||||
salted := ip + "_fotbal_club_2025"
|
||||
h := sha256.Sum256([]byte(salted))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func getScheme(c *gin.Context) string {
|
||||
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
|
||||
return p
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func parseTarget(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", errors.New("empty url")
|
||||
}
|
||||
// allow base64-encoded form as well
|
||||
if !(strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://")) {
|
||||
if dec, err := base64.URLEncoding.DecodeString(raw); err == nil {
|
||||
raw = string(dec)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", errors.New("invalid url")
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) RedirectShort(c *gin.Context) {
|
||||
code := strings.TrimSpace(c.Param("code"))
|
||||
if code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code"})
|
||||
return
|
||||
}
|
||||
var link models.ShortLink
|
||||
err := s.DB.Where("code = ? AND active = ?", code, true).First(&link).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if link.ExpiresAt != nil && time.Now().After(*link.ExpiresAt) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "expired"})
|
||||
return
|
||||
}
|
||||
_ = s.DB.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1")).Error
|
||||
|
||||
// Record click
|
||||
u, _ := url.Parse(link.TargetURL)
|
||||
click := models.LinkClick{
|
||||
ShortLinkID: &link.ID,
|
||||
TargetURL: link.TargetURL,
|
||||
IPHash: hashIPShort(clientIP(c)),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Referrer: c.GetHeader("Referer"),
|
||||
UTMSource: u.Query().Get("utm_source"),
|
||||
UTMMedium: u.Query().Get("utm_medium"),
|
||||
UTMCampaign: u.Query().Get("utm_campaign"),
|
||||
UTMContent: u.Query().Get("utm_content"),
|
||||
UTMTerm: u.Query().Get("utm_term"),
|
||||
}
|
||||
_ = s.DB.Create(&click).Error
|
||||
|
||||
// Umami event (best-effort)
|
||||
cfg := config.AppConfig
|
||||
if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" {
|
||||
svc := services.NewUmamiService()
|
||||
_ = svc.SendEvent(cfg.UmamiWebsiteID, "ShortLink Click", "/s/"+code, link.Title, map[string]any{"code": code, "target": link.TargetURL}, "web")
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, link.TargetURL)
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) RedirectAndTrack(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.Query("u"))
|
||||
target, err := parseTarget(raw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||
return
|
||||
}
|
||||
u, _ := url.Parse(target)
|
||||
click := models.LinkClick{
|
||||
TargetURL: target,
|
||||
IPHash: hashIPShort(clientIP(c)),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Referrer: c.GetHeader("Referer"),
|
||||
UTMSource: u.Query().Get("utm_source"),
|
||||
UTMMedium: u.Query().Get("utm_medium"),
|
||||
UTMCampaign: u.Query().Get("utm_campaign"),
|
||||
UTMContent: u.Query().Get("utm_content"),
|
||||
UTMTerm: u.Query().Get("utm_term"),
|
||||
}
|
||||
_ = s.DB.Create(&click).Error
|
||||
|
||||
cfg := config.AppConfig
|
||||
if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" {
|
||||
svc := services.NewUmamiService()
|
||||
_ = svc.SendEvent(cfg.UmamiWebsiteID, "Link Redirect", "/r", "Link Redirect", map[string]any{"target": target}, "web")
|
||||
}
|
||||
c.Redirect(http.StatusFound, target)
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
|
||||
var body struct {
|
||||
TargetURL string `json:"target_url"`
|
||||
Title string `json:"title"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID *uint `json:"source_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Code string `json:"code"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
target, err := parseTarget(body.TargetURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(body.Code)
|
||||
if code == "" {
|
||||
for i := 0; i < 5; i++ {
|
||||
cnd, _ := randCode(7)
|
||||
var cnt int64
|
||||
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
code = cnd
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if body.Active != nil { active = *body.Active }
|
||||
link := models.ShortLink{
|
||||
Code: code,
|
||||
TargetURL: target,
|
||||
Title: strings.TrimSpace(body.Title),
|
||||
SourceType: strings.TrimSpace(body.SourceType),
|
||||
SourceID: body.SourceID,
|
||||
Active: active,
|
||||
ExpiresAt: body.ExpiresAt,
|
||||
}
|
||||
if err := s.DB.Create(&link).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||
return
|
||||
}
|
||||
scheme := getScheme(c)
|
||||
host := c.Request.Host
|
||||
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, link.Code)
|
||||
c.JSON(http.StatusOK, gin.H{"id": link.ID, "code": link.Code, "short_url": shortURL, "link": link})
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
|
||||
var items []models.ShortLink
|
||||
_ = s.DB.Order("created_at DESC").Limit(200).Find(&items).Error
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return }
|
||||
var link models.ShortLink
|
||||
if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return }
|
||||
start := time.Now().AddDate(0,0,-30)
|
||||
type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` }
|
||||
var rows []Row
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("DATE(created_at)").Order("date ASC").Scan(&rows)
|
||||
var refRows []struct{ Referrer string; Count int64 }
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("referrer, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("referrer").Order("count DESC").Limit(20).Scan(&refRows)
|
||||
var utmRows []struct{ Source, Medium, Campaign string; Count int64 }
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("utm_source, utm_medium, utm_campaign").Order("count DESC").Limit(50).Scan(&utmRows)
|
||||
c.JSON(http.StatusOK, gin.H{"timeseries": rows, "referrers": refRows, "utms": utmRows})
|
||||
}
|
||||
@@ -84,6 +84,13 @@ func SubscribeToNewsletter(db *gorm.DB, email string) error {
|
||||
subscription := NewsletterSubscription{
|
||||
Email: email,
|
||||
IsActive: true,
|
||||
Preferences: datatypes.JSONMap{
|
||||
"blogs": true,
|
||||
"matches": true,
|
||||
"events": true,
|
||||
"scores": true,
|
||||
"weekly": true,
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type LinkClick struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
ShortLinkID *uint `gorm:"index" json:"short_link_id"`
|
||||
TargetURL string `gorm:"size:2048" json:"target_url"`
|
||||
IPHash string `gorm:"size:64;index" json:"ip_hash"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
Referrer string `gorm:"size:512" json:"referrer"`
|
||||
|
||||
UTMSource string `gorm:"size:128" json:"utm_source"`
|
||||
UTMMedium string `gorm:"size:128" json:"utm_medium"`
|
||||
UTMCampaign string `gorm:"size:128" json:"utm_campaign"`
|
||||
UTMContent string `gorm:"size:128" json:"utm_content"`
|
||||
UTMTerm string `gorm:"size:128" json:"utm_term"`
|
||||
}
|
||||
|
||||
func (LinkClick) TableName() string { return "link_clicks" }
|
||||
@@ -95,6 +95,7 @@ func (n *NavigationItem) GetURL() string {
|
||||
"prefetch": "/admin/prefetch",
|
||||
"users": "/admin/uzivatele",
|
||||
"settings": "/admin/nastaveni",
|
||||
"shortlinks": "/admin/shortlinks",
|
||||
"files": "/admin/soubory",
|
||||
"docs": "/admin/docs",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type ShortLink struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Code string `gorm:"size:16;uniqueIndex" json:"code"`
|
||||
TargetURL string `gorm:"size:2048" json:"target_url"`
|
||||
Title string `gorm:"size:512" json:"title"`
|
||||
SourceType string `gorm:"size:32;index" json:"source_type"`
|
||||
SourceID *uint `gorm:"index" json:"source_id"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
ExpiresAt *time.Time `gorm:"index" json:"expires_at"`
|
||||
ClickCount int64 `gorm:"default:0" json:"click_count"`
|
||||
CreatedByID *uint `gorm:"index" json:"created_by_id"`
|
||||
Metadata datatypes.JSONMap `gorm:"type:jsonb" json:"metadata"`
|
||||
}
|
||||
|
||||
func (ShortLink) TableName() string { return "short_links" }
|
||||
@@ -53,6 +53,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
imageProcessingController := &controllers.ImageProcessingController{}
|
||||
articleController := controllers.NewArticleController(db)
|
||||
myuibrixController := &controllers.MyUIbrixController{DB: db}
|
||||
shortLinkController := controllers.NewShortLinkController(db)
|
||||
|
||||
// API v1 group
|
||||
{
|
||||
@@ -151,6 +152,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
protectedEvents.DELETE("/:id", eventController.DeleteEvent)
|
||||
}
|
||||
|
||||
// Shortlinks (protected for editors) - create/list
|
||||
protectedShortlinks := protected.Group("/shortlinks")
|
||||
protectedShortlinks.Use(middleware.RoleAuth("editor"))
|
||||
{
|
||||
protectedShortlinks.POST("", shortLinkController.CreateShortLink)
|
||||
protectedShortlinks.GET("", shortLinkController.ListShortLinks)
|
||||
}
|
||||
|
||||
// Articles (protected - accessible by editors and admins)
|
||||
articles := protected.Group("/articles")
|
||||
articles.Use(middleware.RoleAuth("editor"))
|
||||
@@ -284,6 +293,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
admin.POST("/newsletter/test", contactController.SendNewsletterTest)
|
||||
// New: send prebuilt digest by type and toggle automation
|
||||
admin.POST("/newsletter/send-digest", contactController.SendNewsletterDigest)
|
||||
admin.POST("/newsletter/smtp-test", contactController.AdminSmtpTest)
|
||||
admin.PATCH("/newsletter/enable", contactController.UpdateNewsletterAutomation)
|
||||
// Removed deprecated SMTP test route (use /newsletter/test instead)
|
||||
admin.GET("/newsletter/status", contactController.GetNewsletterStatus)
|
||||
@@ -405,6 +415,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
myuibrix.GET("/preview", myuibrixController.GetElementPreview)
|
||||
myuibrix.GET("/optimize-layout", myuibrixController.OptimizePageLayout)
|
||||
}
|
||||
|
||||
// Short links (admin)
|
||||
shortlinks := admin.Group("/shortlinks")
|
||||
{
|
||||
shortlinks.POST("", shortLinkController.CreateShortLink)
|
||||
shortlinks.GET("", shortLinkController.ListShortLinks)
|
||||
shortlinks.GET("/:id/stats", shortLinkController.GetShortLinkStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +535,10 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// SetupRootRoutes registers endpoints at the root (no /api prefix)
|
||||
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
|
||||
seoController := controllers.NewSEOController(db)
|
||||
shortLinkController := controllers.NewShortLinkController(db)
|
||||
r.GET("/robots.txt", seoController.GetRobotsTXT)
|
||||
r.GET("/sitemap.xml", seoController.GetSitemapXML)
|
||||
// Public short-link redirects and generic tracked redirect
|
||||
r.GET("/s/:code", shortLinkController.RedirectShort)
|
||||
r.GET("/r", shortLinkController.RedirectAndTrack)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type logoAPIResponse struct {
|
||||
LogoURLSVG string `json:"logo_url_svg"`
|
||||
LogoURLPNG string `json:"logo_url_png"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
}
|
||||
|
||||
func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
cid := strings.TrimSpace(clubID)
|
||||
if cid == "" {
|
||||
return "", fmt.Errorf("empty club id")
|
||||
}
|
||||
baseUpload := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(baseUpload) == "" {
|
||||
baseUpload = "./uploads"
|
||||
}
|
||||
destDir := filepath.Join(baseUpload, "logos", "club", cid)
|
||||
_ = os.MkdirAll(destDir, 0o755)
|
||||
|
||||
checkExisting := func() (string, bool) {
|
||||
exts := []string{".svg", ".png", ".jpg", ".jpeg", ".webp"}
|
||||
for _, ext := range exts {
|
||||
p := filepath.Join(destDir, "club-logo"+ext)
|
||||
if fi, err := os.Stat(p); err == nil && fi.Size() > 0 {
|
||||
pub := "/uploads/" + filepath.ToSlash(filepath.Join("logos", "club", cid, "club-logo"+ext))
|
||||
return pub, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if url, ok := checkExisting(); ok {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
req, err := http.NewRequest("GET", "https://logoapi.sportcreative.eu/logos/"+cid+"/json", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "fotbal-club/logo-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("logoapi status %d", resp.StatusCode)
|
||||
}
|
||||
var api logoAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logoURL := strings.TrimSpace(api.LogoURLSVG)
|
||||
if logoURL == "" {
|
||||
logoURL = strings.TrimSpace(api.LogoURLPNG)
|
||||
}
|
||||
if logoURL == "" {
|
||||
logoURL = strings.TrimSpace(api.LogoURL)
|
||||
}
|
||||
if logoURL == "" {
|
||||
return "", fmt.Errorf("no logo url in api response")
|
||||
}
|
||||
|
||||
req2, err := http.NewRequest("GET", logoURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req2.Header.Set("User-Agent", "fotbal-club/logo-cache")
|
||||
req2.Header.Set("Accept", "*/*")
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode < 200 || resp2.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("logo download status %d", resp2.StatusCode)
|
||||
}
|
||||
ct := strings.ToLower(strings.TrimSpace(resp2.Header.Get("Content-Type")))
|
||||
ext := ".png"
|
||||
if strings.Contains(ct, "svg") || strings.HasSuffix(strings.ToLower(logoURL), ".svg") {
|
||||
ext = ".svg"
|
||||
} else if strings.Contains(ct, "webp") || strings.HasSuffix(strings.ToLower(logoURL), ".webp") {
|
||||
ext = ".webp"
|
||||
} else if strings.Contains(ct, "jpeg") || strings.HasSuffix(strings.ToLower(logoURL), ".jpg") || strings.HasSuffix(strings.ToLower(logoURL), ".jpeg") {
|
||||
ext = ".jpg"
|
||||
}
|
||||
|
||||
destTmp := filepath.Join(destDir, "club-logo"+ext+".tmp")
|
||||
dest := filepath.Join(destDir, "club-logo"+ext)
|
||||
f, err := os.Create(destTmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, copyErr := io.Copy(f, resp2.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(destTmp)
|
||||
return "", copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(destTmp)
|
||||
return "", closeErr
|
||||
}
|
||||
_ = os.Rename(destTmp, dest)
|
||||
|
||||
fi, _ := os.Stat(dest)
|
||||
mime := "image/png"
|
||||
if ext == ".svg" {
|
||||
mime = "image/svg+xml"
|
||||
} else if ext == ".jpg" || ext == ".jpeg" {
|
||||
mime = "image/jpeg"
|
||||
} else if ext == ".webp" {
|
||||
mime = "image/webp"
|
||||
}
|
||||
|
||||
publicURL := "/uploads/" + filepath.ToSlash(filepath.Join("logos", "club", cid, "club-logo"+ext))
|
||||
|
||||
var existing models.UploadedFile
|
||||
if db != nil {
|
||||
if err := db.Where("file_path = ?", dest).First(&existing).Error; err != nil {
|
||||
uf := models.UploadedFile{
|
||||
Filename: "club-logo" + ext,
|
||||
FilePath: dest,
|
||||
FileURL: publicURL,
|
||||
MimeType: mime,
|
||||
FileSize: 0,
|
||||
UploadedByID: nil,
|
||||
}
|
||||
if fi != nil {
|
||||
uf.FileSize = fi.Size()
|
||||
}
|
||||
_ = db.Create(&uf).Error
|
||||
}
|
||||
}
|
||||
|
||||
return publicURL, nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -475,46 +474,62 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTML builders
|
||||
|
||||
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
|
||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||
|
||||
// Build tracked link
|
||||
token, _ := utils.GenerateSubscriberToken("newsletter@system", 60*24*30)
|
||||
trackedURL := fmt.Sprintf("%s/api/v1/email/click?u=%s&t=%s",
|
||||
strings.TrimSuffix(config.AppConfig.PublicAPIBaseURL, "/"),
|
||||
url.QueryEscape(articleURL),
|
||||
url.QueryEscape(token))
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
// 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: 20px;">Nový článek na webu</h2>
|
||||
|
||||
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">%s</h3>
|
||||
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">%s</p>
|
||||
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
|
||||
<a href="%s/newsletter/preferences?token=%s" style="color: #2563eb;">Spravovat předvolby</a>
|
||||
</p>
|
||||
<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>
|
||||
`, htmlEsc(article.Title), htmlEsc(article.Excerpt), trackedURL, baseFE, url.QueryEscape(token))
|
||||
|
||||
return html
|
||||
`, 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(`
|
||||
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>
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
|
||||
art := readJSON(filepath.Join(cacheDir, "articles.json"))
|
||||
ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json"))
|
||||
facr:= readJSON(filepath.Join(cacheDir, "facr_club_info.json"))
|
||||
// Club name for subject personalization (fallback to default)
|
||||
set := readJSON(filepath.Join(cacheDir, "settings.json"))
|
||||
sm := asMap(set)
|
||||
clubName := strings.TrimSpace(str(sm["club_name"], ""))
|
||||
if clubName == "" { clubName = "Fotbal Club" }
|
||||
|
||||
sections := make([]string, 0, 4)
|
||||
|
||||
@@ -73,10 +78,10 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return "Fotbal Club – přehled", "<p>Pro vybrané preference nyní nemáme novinky.</p>"
|
||||
return fmt.Sprintf("%s – přehled", clubName), "<p>Pro vybrané preference nyní nemáme novinky.</p>"
|
||||
}
|
||||
|
||||
subject = "Fotbal Club – novinky a zápasy"
|
||||
subject = fmt.Sprintf("%s – novinky a zápasy", clubName)
|
||||
html = strings.Join(sections, "\n\n")
|
||||
return subject, html
|
||||
}
|
||||
@@ -125,12 +130,22 @@ func pickUpcomingEvents(v any, n int) []Event {
|
||||
Time: str(m["time"], ""),
|
||||
Url: str(m["url"], ""),
|
||||
}
|
||||
if e.Date == "" {
|
||||
st := str(m["start_time"], "")
|
||||
if st != "" {
|
||||
if tm, err := time.Parse(time.RFC3339, st); err == nil {
|
||||
lt := tm.In(time.Local)
|
||||
e.Date = lt.Format("2006-01-02")
|
||||
e.Time = lt.Format("15:04")
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Match struct { Home, Away, Date, Time, Competition, Link, Score string }
|
||||
type Match struct { Home, Away, Date, Time, Competition, CompCode, Link, Score string }
|
||||
|
||||
func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
|
||||
compSet := make(map[string]bool)
|
||||
@@ -142,7 +157,9 @@ func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
|
||||
ts := parseDateTimeISO(m.Date, m.Time)
|
||||
if ts.IsZero() || ts.Before(now) { continue }
|
||||
if len(compSet) > 0 {
|
||||
if !compSet[strings.ToLower(m.Competition)] { continue }
|
||||
nameKey := strings.ToLower(strings.TrimSpace(m.Competition))
|
||||
codeKey := strings.ToLower(strings.TrimSpace(m.CompCode))
|
||||
if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue }
|
||||
}
|
||||
out = append(out, m)
|
||||
if len(out) >= n { break }
|
||||
@@ -163,7 +180,9 @@ func pickRecentResultsFromFACR(v any, competitions []string, n int, window time.
|
||||
// treat as result if score like "2:1" exists
|
||||
if m.Score == "" || !strings.Contains(m.Score, ":") { continue }
|
||||
if len(compSet) > 0 {
|
||||
if !compSet[strings.ToLower(m.Competition)] { continue }
|
||||
nameKey := strings.ToLower(strings.TrimSpace(m.Competition))
|
||||
codeKey := strings.ToLower(strings.TrimSpace(m.CompCode))
|
||||
if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue }
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
@@ -188,19 +207,20 @@ func facrAllMatches(v any) []Match {
|
||||
for _, c := range asList(comps) {
|
||||
cm := asMap(c)
|
||||
compName := str(cm["name"], str(cm["code"], ""))
|
||||
compCode := str(cm["code"], "")
|
||||
for _, mm := range asList(cm["matches"]) {
|
||||
out = append(out, toMatch(asMap(mm), compName))
|
||||
out = append(out, toMatch(asMap(mm), compName, compCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
// flat matches fallback
|
||||
for _, mm := range asList(m["matches"]) {
|
||||
out = append(out, toMatch(asMap(mm), ""))
|
||||
out = append(out, toMatch(asMap(mm), "", ""))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toMatch(m map[string]any, comp string) Match {
|
||||
func toMatch(m map[string]any, compName, compCode string) Match {
|
||||
dt := str(m["date_time"], "")
|
||||
var date, tm string
|
||||
if dt != "" && strings.Contains(dt, " ") {
|
||||
@@ -215,7 +235,8 @@ func toMatch(m map[string]any, comp string) Match {
|
||||
Away: str(m["away"], ""),
|
||||
Date: date,
|
||||
Time: tm,
|
||||
Competition: str(m["competition"], str(m["competition_name"], comp)),
|
||||
Competition: str(m["competition"], str(m["competition_name"], compName)),
|
||||
CompCode: str(m["competition_code"], compCode),
|
||||
Link: str(m["facr_link"], str(m["report_url"], "#")),
|
||||
Score: str(m["score"], ""),
|
||||
}
|
||||
@@ -224,10 +245,19 @@ func toMatch(m map[string]any, comp string) Match {
|
||||
func parseDateTimeISO(d, t string) time.Time {
|
||||
if d == "" { return time.Time{} }
|
||||
if t == "" { t = "00:00" }
|
||||
layout := "2006-01-02T15:04:05"
|
||||
// try shorter HH:MM format
|
||||
if len(t) == 5 { return parseTime("2006-01-02T15:04", d+"T"+t) }
|
||||
return parseTime(layout, d+"T"+t)
|
||||
if strings.Contains(d, ".") {
|
||||
if len(t) == 5 {
|
||||
if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm }
|
||||
}
|
||||
if tm := parseTime("02.01.2006 15:04:05", d+" "+t); !tm.IsZero() { return tm }
|
||||
if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm }
|
||||
}
|
||||
if len(t) == 5 {
|
||||
if tm := parseTime("2006-01-02T15:04", d+"T"+t); !tm.IsZero() { return tm }
|
||||
return parseTime("2006-01-02 15:04", d+" "+t)
|
||||
}
|
||||
if tm := parseTime("2006-01-02T15:04:05", d+"T"+t); !tm.IsZero() { return tm }
|
||||
return parseTime("2006-01-02 15:04:05", d+" "+t)
|
||||
}
|
||||
|
||||
func parseTime(layout, s string) time.Time {
|
||||
|
||||
Reference in New Issue
Block a user