mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
1469 lines
45 KiB
Go
1469 lines
45 KiB
Go
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í e‑mail 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í 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")
|
||
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),
|
||
})
|
||
}
|