Files
MyClub/internal/controllers/contact_controller.go
T
Tomas Dvorak f5b6f83974 dev day #99
2025-11-21 08:44:44 +01:00

1469 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/email"
"fotbal-club/pkg/logger"
"fotbal-club/pkg/utils"
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
"gopkg.in/mail.v2"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type ContactController struct {
DB *gorm.DB
emailService email.EmailService
}
// GetContactMessages lists contact messages (admin)
func (cc *ContactController) GetContactMessages(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Pagination
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
if page <= 0 {
page = 1
}
if limit <= 0 || limit > 200 {
limit = 50
}
// Filters
search := strings.TrimSpace(c.DefaultQuery("search", ""))
isReadParam := strings.TrimSpace(c.DefaultQuery("isRead", ""))
var isRead *bool
if isReadParam != "" {
v := strings.ToLower(isReadParam)
if v == "true" || v == "1" {
t := true
isRead = &t
} else if v == "false" || v == "0" {
f := false
isRead = &f
}
}
// Sorting (map UI fields to DB columns)
sortBy := strings.TrimSpace(c.DefaultQuery("sortBy", "createdAt"))
sortOrder := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sortOrder", "desc")))
if sortOrder != "asc" {
sortOrder = "desc"
}
sortField := "created_at"
switch sortBy {
case "name":
sortField = "name"
case "email":
sortField = "email"
case "subject":
sortField = "subject"
case "createdAt", "created_at":
sortField = "created_at"
}
// Build query
q := cc.DB.Model(&models.ContactMessage{})
if search != "" {
s := "%" + strings.ToLower(search) + "%"
q = q.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(subject) LIKE ? OR LOWER(message) LIKE ?", s, s, s, s)
}
if isRead != nil {
q = q.Where("is_read = ?", *isRead)
}
// Count total
var total int64
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
// Fetch page
var items []models.ContactMessage
offset := (page - 1) * limit
if err := q.Order(sortField + " " + sortOrder).Offset(offset).Limit(limit).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
// Compute pages (ceil)
pages := 1
if limit > 0 {
pages = (int(total) + limit - 1) / limit
if pages == 0 {
pages = 1
}
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"pagination": gin.H{
"total": total,
"page": page,
"limit": limit,
"pages": pages,
},
})
}
// MarkMessageAsRead marks a message as read (admin)
func (cc *ContactController) MarkMessageAsRead(c *gin.Context) {
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 || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id"})
return
}
if err := models.MarkMessageAsRead(cc.DB, uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func NewContactController(db *gorm.DB, emailService email.EmailService) *ContactController {
return &ContactController{DB: db, emailService: emailService}
}
// recalcNewsletterAutomationEnabled enables automation when there is at least one
// active subscriber; disables it when there are none. Persists to Settings and
// updates the in-memory AppConfig flag used by the scheduler.
func (cc *ContactController) recalcNewsletterAutomationEnabled() {
var active int64
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active).Error
enabled := active > 0
// Persist flag and, when enabling for the first time, ensure weekly digest is active with sane defaults
var s models.Settings
_ = cc.DB.First(&s).Error
if s.ID == 0 {
s = models.Settings{}
}
changed := false
if s.NewsletterEnabled != enabled {
s.NewsletterEnabled = enabled
changed = true
}
if enabled {
// Auto-activate weekly digest and preset schedule if not configured
if !s.EnableWeekly {
s.EnableWeekly = true
changed = true
}
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
s.NewsletterWeeklyDay = "sun"
changed = true
}
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
s.NewsletterWeeklyHour = 9
changed = true
}
}
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
} else if changed {
_ = cc.DB.Save(&s).Error
}
// Update runtime
if config.AppConfig != nil {
config.AppConfig.NewsletterEnabled = enabled
}
}
// --- Newsletter admin: subscribers CRUD & preview/test ---
// GetNewsletterSubscribers returns all newsletter subscribers (admin)
func (cc *ContactController) GetNewsletterSubscribers(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var subs []models.NewsletterSubscription
if err := cc.DB.Order("created_at DESC").Find(&subs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"})
return
}
c.JSON(http.StatusOK, subs)
}
// UpdateNewsletterSubscriberStatus toggles is_active for a subscriber (admin)
func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
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 || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var body struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 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": "database error"})
}
return
}
sub.IsActive = body.IsActive
if err := cc.DB.Save(&sub).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
// Recalculate automation flag after status change
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, sub)
}
// UpdateNewsletterSubscriberPreferences updates preferences JSON (admin)
func (cc *ContactController) UpdateNewsletterSubscriberPreferences(c *gin.Context) {
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 || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var prefsIn map[string]interface{}
if err := c.ShouldBindJSON(&prefsIn); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences"})
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": "database error"})
}
return
}
m := datatypes.JSONMap{}
for k, v := range prefsIn {
m[k] = v
}
sub.Preferences = m
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, sub)
}
// DeleteNewsletterSubscriber removes a subscriber (admin)
func (cc *ContactController) DeleteNewsletterSubscriber(c *gin.Context) {
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 || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := cc.DB.Delete(&models.NewsletterSubscription{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
return
}
// Recalculate automation flag after deletion
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// PreviewNewsletter builds subject+HTML for given preferences (admin)
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"`
}
_ = c.ShouldBindJSON(&input)
// Determine requested types; support "weekly"=true shortcut
types := []string{}
freq := "daily"
if input.Preferences != nil {
if v, ok := input.Preferences["weekly"].(bool); ok && v {
types = []string{"blogs", "events", "matches", "scores"}
freq = "weekly"
}
}
if len(types) == 0 {
for _, k := range []string{"blogs", "events", "matches", "scores"} {
if input.Preferences != nil {
if v, ok := input.Preferences[k].(bool); ok && v {
types = append(types, k)
}
}
}
}
if len(types) == 0 {
types = []string{"blogs", "matches"}
}
comps := []string{}
if input.Preferences != nil {
if raw, ok := input.Preferences["competitions"]; ok {
if s, ok2 := raw.(string); ok2 && strings.TrimSpace(s) != "" {
for _, p := range strings.Split(s, ",") {
v := strings.TrimSpace(p)
if v != "" {
comps = append(comps, v)
}
}
}
}
}
prefs := services.NewsletterPrefs{Email: "preview@local", ContentTypes: types, Competitions: comps, Frequency: freq}
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
if strings.TrimSpace(html) == "" {
html = "<p>Pro zadané preference nyní nemáme novinky.</p>"
}
if strings.TrimSpace(subj) == "" {
subj = "Newsletter náhled"
}
c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html})
}
// SendNewsletterTest sends a test email either to provided email(s) or to AdminEmail
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"`
}
_ = c.ShouldBindJSON(&input)
// Build sample newsletter content using digest builder for the selected type
t := strings.ToLower(strings.TrimSpace(input.Type))
if t == "" {
t = "newsletter"
}
// Recognize digest types; default to generic newsletter template with minimal body
var subj, html string
switch t {
case "blogs", "events", "matches", "scores", "weekly":
types := []string{}
freq := "daily"
if t == "weekly" {
types = []string{"blogs", "events", "matches", "scores"}
freq = "weekly"
} else {
types = []string{t}
}
prefs := services.NewsletterPrefs{Email: "test@local", ContentTypes: types, Competitions: []string{}, Frequency: freq}
subj, html = services.BuildNewsletterDigest("cache/prefetch", prefs)
if subj == "" {
subj = "Test newsletter"
}
if html == "" {
html = "<p>Testovací obsah není k dispozici.</p>"
}
default:
subj = "Test newsletter"
html = "<p>Toto je testovací email newsletteru.</p>"
}
// Prepare recipients
recipients := []string{}
for _, e := range input.Emails {
if v := strings.TrimSpace(e); v != "" {
recipients = append(recipients, v)
}
}
if strings.TrimSpace(input.Email) != "" {
recipients = append(recipients, strings.TrimSpace(input.Email))
}
if len(recipients) == 0 {
// fallback to admin email
to := strings.TrimSpace(config.AppConfig.AdminEmail)
if to == "" {
to = strings.TrimSpace(config.AppConfig.SMTPFrom)
}
if to == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient specified"})
return
}
recipients = []string{to}
}
// Send one-by-one to exercise per-recipient template and tracking
sent := []string{}
for _, r := range recipients {
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: []string{r}}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Test send failed to %s: %v", r, err)
continue
}
sent = append(sent, r)
time.Sleep(50 * time.Millisecond)
}
if len(sent) == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
return
}
// Match frontend expectations
if len(sent) == 1 {
c.JSON(http.StatusOK, gin.H{"message": "Test email sent", "recipient": sent[0], "type": t})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test email sent", "recipients": sent, "type": t})
}
// --- Newsletter public endpoints ---
// SubscribeToNewsletter creates or re-activates a subscription
func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
var input struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email is required"})
return
}
emailStr := strings.ToLower(strings.TrimSpace(input.Email))
if err := models.SubscribeToNewsletter(cc.DB, emailStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"})
return
}
// Build links (preferences/unsubscribe)
token, _ := utils.GenerateSubscriberToken(emailStr, 60*24*30)
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
manageURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/unsubscribe/" + url.QueryEscape(emailStr)
// Send styled newsletter welcome (best-effort)
_ = cc.emailService.SendNewsletterWelcome(&email.NewsletterWelcomeData{Email: emailStr, UnsubscribeLink: unsubscribeURL})
// Auto-create user account for the subscriber (fan role) if not exists
var existing models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", emailStr).First(&existing).Error; err == gorm.ErrRecordNotFound {
// Generate a random initial password
pwdBytes := make([]byte, 8)
if _, err := rand.Read(pwdBytes); err != nil {
// fallback to timestamp-derived hex if RNG fails
pwdBytes = []byte(fmt.Sprintf("%d", time.Now().UnixNano()))
}
genPass := hex.EncodeToString(pwdBytes)
if len(genPass) < 8 {
genPass = genPass + "12345678"
}
hashed, herr := utils.HashPassword(genPass)
if herr == nil {
u := models.User{Email: strings.ToLower(emailStr), Password: hashed, Role: "fan", IsActive: true}
if err := cc.DB.Create(&u).Error; err == nil {
// Send account created email with login + manage links (best-effort)
loginURL := baseFE + "/login"
// Reset URL can point to forgot-password page (token flow is initiated by user)
resetURL := baseFE + "/forgot-password"
_ = cc.emailService.SendEmail(&email.EmailData{
Subject: "Váš fan účet byl vytvořen",
To: []string{emailStr},
Template: "fan_account_created",
Data: map[string]interface{}{
"Email": emailStr,
"Password": genPass,
"LoginURL": loginURL,
"ResetURL": resetURL,
"ManageURL": manageURL,
"UnsubscribeURL": unsubscribeURL,
},
})
}
}
}
// Additionally, send a minimal confirmation using newsletter template with manage link (best-effort)
_ = cc.emailService.SendNewsletter(&email.NewsletterData{
Subject: "Vítejte v odběru",
Content: fmt.Sprintf("<p>Děkujeme za přihlášení. Spravujte své preference <a href=\"%s\">zde</a>.</p>", manageURL),
Recipients: []string{emailStr},
})
// Recalculate automation after (re)subscription
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"message": "Subscribed"})
}
// SetupNewsletterPreferences accepts token or email+preferences to initialize preferences
func (cc *ContactController) SetupNewsletterPreferences(c *gin.Context) {
var input struct {
Email string `json:"email"`
Token string `json:"token"`
Preferences map[string]interface{} `json:"preferences"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
emailStr := strings.ToLower(strings.TrimSpace(input.Email))
if emailStr == "" && strings.TrimSpace(input.Token) != "" {
if em, err := utils.ParseSubscriberToken(strings.TrimSpace(input.Token)); err == nil {
emailStr = em
}
}
if emailStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email or token required"})
return
}
var sub models.NewsletterSubscription
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
// Create new
sub = models.NewsletterSubscription{Email: emailStr, IsActive: true}
}
m := datatypes.JSONMap{}
for k, v := range input.Preferences {
m[k] = v
}
sub.Preferences = m
sub.IsActive = true
if sub.ID == 0 {
_ = cc.DB.Create(&sub).Error
} else {
_ = cc.DB.Save(&sub).Error
}
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
}
// GetNewsletterPreferencesByToken returns preferences for token holder
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
tok := strings.TrimSpace(c.Query("token"))
if tok == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token required"})
return
}
emailStr, err := utils.ParseSubscriberToken(tok)
if err != nil || strings.TrimSpace(emailStr) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"})
return
}
var sub models.NewsletterSubscription
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
// Return empty defaults
c.JSON(http.StatusOK, gin.H{"email": emailStr, "preferences": map[string]any{}})
return
}
c.JSON(http.StatusOK, gin.H{"email": sub.Email, "preferences": sub.Preferences, "is_active": sub.IsActive})
}
// SaveNewsletterPreferencesByToken saves preferences provided with a token
func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
var input struct {
Token string `json:"token"`
Preferences map[string]interface{} `json:"preferences"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
emailStr, err := utils.ParseSubscriberToken(strings.TrimSpace(input.Token))
if err != nil || strings.TrimSpace(emailStr) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"})
return
}
var sub models.NewsletterSubscription
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
// Create subscription if missing
sub = models.NewsletterSubscription{Email: emailStr, IsActive: true}
}
m := datatypes.JSONMap{}
for k, v := range input.Preferences {
m[k] = v
}
sub.Preferences = m
sub.IsActive = true
if sub.ID == 0 {
_ = cc.DB.Create(&sub).Error
} else {
_ = cc.DB.Save(&sub).Error
}
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved", "email": sub.Email, "preferences": sub.Preferences})
}
// UnsubscribeByToken disables subscription using a token
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
var input struct {
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
emailStr, err := utils.ParseSubscriberToken(strings.TrimSpace(input.Token))
if err != nil || strings.TrimSpace(emailStr) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"})
return
}
if err := models.UnsubscribeFromNewsletter(cc.DB, emailStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
return
}
// Recalculate automation after unsubscribe
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"message": "Unsubscribed"})
}
// SubmitContactForm handles public contact form submissions
// POST /api/v1/contact
func (cc *ContactController) SubmitContactForm(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required"`
Subject string `json:"subject" binding:"required"`
Message string `json:"message" binding:"required"`
Source string `json:"source"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
name := strings.TrimSpace(input.Name)
emailStr := strings.TrimSpace(input.Email)
subject := strings.TrimSpace(input.Subject)
message := strings.TrimSpace(input.Message)
if name == "" || emailStr == "" || subject == "" || message == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "All fields are required"})
return
}
if !strings.Contains(emailStr, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email is required"})
return
}
if s, _ := services.FilterBadWords(subject); s != "" {
subject = s
}
if m, _ := services.FilterBadWords(message); m != "" {
message = m
}
ip := c.ClientIP()
ua := c.GetHeader("User-Agent")
src := strings.ToLower(strings.TrimSpace(input.Source))
switch src {
case "sponsor":
case "contact":
default:
src = "contact"
}
msg := models.ContactMessage{
Name: name,
Email: emailStr,
Subject: subject,
Message: message,
Source: src,
IPAddress: ip,
UserAgent: ua,
}
if err := cc.DB.Create(&msg).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"})
return
}
go func(nm, em, subj, msgBody, ipAddr, agent string) {
_ = cc.emailService.SendContactForm(&email.ContactFormData{
Name: nm,
Email: em,
Subject: subj,
Message: msgBody,
IPAddress: ipAddr,
UserAgent: agent,
})
}(name, emailStr, subject, message, ip, ua)
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
}
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")
if !ok || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
user := u.(*models.User)
email := strings.TrimSpace(strings.ToLower(user.Email))
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "User email not available"})
return
}
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
}
// Ensure automation is on when we just activated a subscription for user
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"token": token})
}
// 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"`
}
if err := c.ShouldBindJSON(&input); err != nil {
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] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown digest type"})
return
}
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"})
return
}
if len(subscribers) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No active subscribers"})
return
}
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"
} else {
prefs.ContentTypes = []string{t}
}
if strings.TrimSpace(input.Competitions) != "" {
for _, p := range strings.Split(input.Competitions, ",") {
if v := strings.TrimSpace(p); v != "" {
prefs.Competitions = append(prefs.Competitions, v)
}
}
}
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
if strings.TrimSpace(html) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No content for selected digest"})
return
}
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"
}
// Send per-recipient to avoid failing the whole request if some addresses bounce/misconfigure
success := 0
failed := 0
failedList := make([]string, 0)
for _, r := range recipients {
d := &email.NewsletterData{Subject: subj, Content: html, Recipients: []string{r}}
if err := cc.emailService.SendNewsletter(d); err != nil {
failed++
failedList = append(failedList, r)
} else {
success++
}
time.Sleep(50 * time.Millisecond)
}
if success == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter to any recipient", "failed": failed})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Digest newsletter sent", "recipients": success, "failed": failed, "type": t})
}
// 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"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
var 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
}
if config.AppConfig != nil {
config.AppConfig.NewsletterEnabled = input.Enabled
}
c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled})
}
// 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, active int64
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
var s models.Settings
_ = cc.DB.First(&s).Error
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)
}
}
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
}
}
next := time.Now().Add(interval)
// Compute next scheduled weekly time (exact), using settings (default Sun 09:00)
weeklyDay := strings.ToLower(strings.TrimSpace(s.NewsletterWeeklyDay))
if weeklyDay == "" {
weeklyDay = "sun"
}
weeklyHour := s.NewsletterWeeklyHour
if weeklyHour < 0 || weeklyHour > 23 {
weeklyHour = 9
}
// find next occurrence
now := time.Now()
target := time.Date(now.Year(), now.Month(), now.Day(), weeklyHour, 0, 0, 0, now.Location())
toWD := func(d string) time.Weekday {
switch d {
case "mon":
return time.Monday
case "tue":
return time.Tuesday
case "wed":
return time.Wednesday
case "thu":
return time.Thursday
case "fri":
return time.Friday
case "sat":
return time.Saturday
default:
return time.Sunday
}
}
for i := 0; i < 8; i++ {
if target.Weekday() == toWD(weeklyDay) && target.After(now) {
break
}
target = target.Add(24 * time.Hour)
}
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,
// Scheduling detail
"weekly_enabled": s.EnableWeekly,
"weekly_day": weeklyDay,
"weekly_hour": weeklyHour,
"weekly_next_scheduled": target,
"matches_enabled": s.EnableMatchReminders,
"reminder_lead_hours": s.NewsletterReminderLeadHours,
"results_enabled": s.EnableResults,
"quiet_start": s.NewsletterQuietStart,
"quiet_end": s.NewsletterQuietEnd,
})
}
// SendNewsletter sends a newsletter to all active subscribers (admin only)
// @Summary Send newsletter
// @Description Sends a newsletter to all active subscribers (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param input body map[string]string true "Newsletter content (subject and body)"
// @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/send [post]
func (cc *ContactController) SendNewsletter(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input struct {
Subject string `json:"subject" binding:"required"`
Body string `json:"body"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
return
}
// Support both 'body' (backend) and 'content' (frontend variant)
bodyText := strings.TrimSpace(input.Body)
if bodyText == "" {
bodyText = strings.TrimSpace(input.Content)
}
if bodyText == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Newsletter body/content is required"})
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"})
return
}
if len(subscribers) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No active subscribers"})
return
}
// Build recipient 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
}
logger.Info("[SendNewsletter] sending to %d active subscribers", len(recipients))
// Send via email service
data := &email.NewsletterData{
Subject: input.Subject,
Content: bodyText,
Recipients: recipients,
}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send newsletter: %v", err)
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send newsletter", "details": err.Error()})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send newsletter"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Newsletter sent successfully", "recipients": len(recipients)})
}
// UnsubscribeFromNewsletter handles newsletter unsubscription
// @Summary Unsubscribe from newsletter
// @Description Handles newsletter unsubscription requests
// @Tags newsletter
// @Produce json
// @Param email path string true "Subscriber email"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /api/v1/newsletter/unsubscribe/{email} [post]
func (cc *ContactController) UnsubscribeFromNewsletter(c *gin.Context) {
email := c.Param("email")
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email is required"})
return
}
// Set subscription as inactive instead of deleting
result := cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", email).Update("is_active", false)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
return
}
// Recalculate automation after unsubscribe
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"message": "Successfully unsubscribed from newsletter"})
}
// GetContactMessage returns a single contact message by ID (admin only)
// @Summary Get contact message
// @Description Returns a single contact message by ID (admin only)
// @Tags admin
// @Security Bearer
// @Produce json
// @Param id path int true "Message ID"
// @Success 200 {object} models.ContactMessage
// @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/contact-messages/{id} [get]
func (cc *ContactController) GetContactMessage(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"})
return
}
var message models.ContactMessage
if err := cc.DB.First(&message, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch message"})
}
return
}
c.JSON(http.StatusOK, message)
}
// DeleteContactMessage deletes a contact message (admin only)
// @Summary Delete contact message
// @Description Deletes a contact message by ID (admin only)
// @Tags admin
// @Security Bearer
// @Produce json
// @Param id path int true "Message 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
// @Router /api/v1/admin/contact-messages/{id} [delete]
func (cc *ContactController) DeleteContactMessage(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"})
return
}
result := cc.DB.Delete(&models.ContactMessage{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Message deleted successfully"})
}
// DeleteContactMessages deletes multiple contact messages (admin only)
// @Summary Delete multiple contact messages
// @Description Deletes multiple contact messages by their IDs (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param ids body []int true "Array of message IDs to delete"
// @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/contact-messages [delete]
func (cc *ContactController) DeleteContactMessages(c *gin.Context) {
var ids []int
if err := c.ShouldBindJSON(&ids); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if len(ids) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No message IDs provided"})
return
}
result := cc.DB.Delete(&models.ContactMessage{}, ids)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete messages"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "No messages found with the provided IDs"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Successfully deleted %d message(s)", result.RowsAffected),
})
}
// ForwardContactMessage forwards a contact message to a specified email (admin only)
// @Summary Forward contact message
// @Description Forwards a contact message to a specified email address (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param id path int true "Message ID"
// @Param input body map[string]string true "{ to_email: string }"
// @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/contact-messages/{id}/forward [post]
func (cc *ContactController) ForwardContactMessage(c *gin.Context) {
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 message ID"})
return
}
var input struct {
ToEmail string `json:"to_email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email address is required"})
return
}
// Fetch the message
var message models.ContactMessage
if err := cc.DB.First(&message, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Prepare email data for forwarding (Czech subject)
forwardData := &email.EmailData{
Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject),
To: []string{input.ToEmail},
Template: "contact_form",
Data: struct {
Name string
Email string
Subject string
Message string
Time string
IP string
Agent string
}{
Name: message.Name,
Email: message.Email,
Subject: message.Subject,
Message: message.Message,
Time: message.CreatedAt.Format(time.RFC1123Z),
IP: message.IPAddress,
Agent: message.UserAgent,
},
}
if err := cc.emailService.SendEmail(forwardData); err != nil {
logger.Error("Failed to forward contact message %d to %s: %v", message.ID, input.ToEmail, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to forward message"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Message forwarded"})
}
// @Summary Forward all contact messages
// @Description Forwards all contact messages to a specified email address (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param input body map[string]string true "{ to_email: string, to_emails: []string, save_default: bool }"
// @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/contact-messages/forward-all [post]
func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input struct {
ToEmail string `json:"to_email"`
ToEmails []string `json:"to_emails"`
SaveDefault bool `json:"save_default"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
// Build recipients list (supports comma/semicolon/space separated string or array)
recipients := make([]string, 0)
add := func(s string) {
v := strings.TrimSpace(s)
if v != "" {
recipients = append(recipients, v)
}
}
if len(input.ToEmails) > 0 {
for _, e := range input.ToEmails {
add(e)
}
}
if input.ToEmail != "" {
// split by common separators to allow multiple addresses in a single string
parts := strings.FieldsFunc(input.ToEmail, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
if len(parts) > 1 {
for _, p := range parts {
add(p)
}
} else {
add(input.ToEmail)
}
}
// Deduplicate
if len(recipients) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"})
return
}
uniq := make(map[string]struct{})
out := make([]string, 0, len(recipients))
for _, e := range recipients {
v := strings.TrimSpace(strings.ToLower(e))
if v == "" {
continue
}
if _, ok := uniq[v]; ok {
continue
}
uniq[v] = struct{}{}
out = append(out, e)
}
recipients = out
// Optionally save as default auto-forward list in Settings
if input.SaveDefault {
var set models.Settings
if err := cc.DB.First(&set).Error; err != nil {
if err == gorm.ErrRecordNotFound {
set = models.Settings{}
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Create(&set).Error
}
} else {
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Save(&set).Error
}
}
// Fetch all messages
var messages []models.ContactMessage
if err := cc.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
if len(messages) == 0 {
// Even if there are no messages now, ensure auto-forward is configured
if !input.SaveDefault {
var set models.Settings
if err := cc.DB.First(&set).Error; err != nil {
if err == gorm.ErrRecordNotFound {
set = models.Settings{}
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Create(&set).Error
}
} else {
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Save(&set).Error
}
}
c.JSON(http.StatusOK, gin.H{
"message": "Automatické přeposílání je nastaveno. Zatím nejsou žádné zprávy k přeposlání.",
"count": 0,
})
return
}
// Forward all messages asynchronously
go func(msgs []models.ContactMessage, dest []string) {
successCount := 0
for _, message := range msgs {
forwardData := &email.EmailData{
Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject),
To: dest,
Template: "contact_form",
Data: struct {
Name string
Email string
Subject string
Message string
Time string
IP string
Agent string
}{
Name: message.Name,
Email: message.Email,
Subject: message.Subject,
Message: message.Message,
Time: message.CreatedAt.Format(time.RFC1123Z),
IP: message.IPAddress,
Agent: message.UserAgent,
},
}
if err := cc.emailService.SendEmail(forwardData); err != nil {
logger.Error("Failed to forward contact message %d to %v: %v", message.ID, dest, err)
} else {
successCount++
}
}
logger.Info("Forwarded %d of %d contact messages to %v", successCount, len(msgs), dest)
}(messages, recipients)
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Přeposílám %d zpráv na: %s", len(messages), strings.Join(recipients, ", ")),
"count": len(messages),
})
}