This commit is contained in:
Tomas Dvorak
2025-10-28 22:38:27 +01:00
parent 3d621e2187
commit 823fabee02
106 changed files with 9011 additions and 3930 deletions
+146 -510
View File
@@ -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í email 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{}