mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
1507 lines
50 KiB
Go
1507 lines
50 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"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ContactController struct {
|
|
DB *gorm.DB
|
|
emailService email.EmailService
|
|
}
|
|
|
|
// SendNewsletterDigest builds and sends a digest newsletter based on a template type (admin only)
|
|
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
|
|
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
|
|
}
|
|
|
|
// 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 digest content once based on selected type
|
|
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 list
|
|
recipients := make([]string, 0, len(subscribers))
|
|
for _, s := range subscribers {
|
|
if s.Email != "" {
|
|
recipients = append(recipients, s.Email)
|
|
}
|
|
}
|
|
if len(recipients) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"})
|
|
return
|
|
}
|
|
|
|
if subj == "" {
|
|
subj = strings.Title(t) + " digest"
|
|
}
|
|
|
|
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
|
logger.Error("Failed to send digest newsletter: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Digest newsletter sent", "recipients": len(recipients), "type": t})
|
|
}
|
|
|
|
// UpdateNewsletterAutomation toggles the automated newsletter scheduler at runtime (non-persistent)
|
|
// PATCH /api/v1/admin/newsletter/enable { enabled: boolean }
|
|
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
|
|
}
|
|
// Persist to Settings (singleton row)
|
|
var s models.Settings
|
|
_ = cc.DB.First(&s).Error // ignore not found
|
|
if s.ID == 0 {
|
|
s = models.Settings{}
|
|
}
|
|
s.NewsletterEnabled = input.Enabled
|
|
if s.ID == 0 {
|
|
if err := cc.DB.Create(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
|
return
|
|
}
|
|
} else if err := cc.DB.Save(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
|
return
|
|
}
|
|
// Flip the in-memory config flag; effective immediately for next tick
|
|
if config.AppConfig != nil {
|
|
config.AppConfig.NewsletterEnabled = input.Enabled
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled})
|
|
}
|
|
|
|
// GetNewsletterStatus returns basic scheduling/status info for newsletters (admin only)
|
|
// @Summary Newsletter status
|
|
// @Description Returns subscriber stats and next approximate run time based on interval
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Router /api/v1/admin/newsletter/status [get]
|
|
func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
|
if c.GetString("userRole") != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
var total int64
|
|
var active int64
|
|
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
|
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
|
|
|
var subs []models.NewsletterSubscription
|
|
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
|
sample := make([]string, 0, len(subs))
|
|
for _, s := range subs {
|
|
if s.Email != "" {
|
|
sample = append(sample, s.Email)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"total_subscribers": total,
|
|
"active_subscribers": active,
|
|
"sample_recipients": sample,
|
|
"interval_minutes": int(interval.Minutes()),
|
|
"next_approximate": next,
|
|
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
|
|
})
|
|
}
|
|
|
|
// PreviewNewsletter builds a digest preview (subject + html) for admin without sending
|
|
// @Summary Preview newsletter digest (admin)
|
|
// @Description Returns subject and HTML for a digest newsletter using current cache and optional preferences
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param prefs body map[string]interface{} false "Optional { preferences: { blogs, matches, events, scores, competitions } }"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Router /api/v1/admin/newsletter/preview [post]
|
|
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)
|
|
|
|
// Normalize preferences to NewsletterPrefs
|
|
prefs := services.NewsletterPrefs{
|
|
Email: "preview@local",
|
|
ContentTypes: []string{},
|
|
Competitions: []string{},
|
|
Frequency: "daily",
|
|
}
|
|
if m := input.Preferences; m != nil {
|
|
if b, ok := m["blogs"].(bool); ok && b {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
|
|
}
|
|
if b, ok := m["events"].(bool); ok && b {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "events")
|
|
}
|
|
if b, ok := m["matches"].(bool); ok && b {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "matches")
|
|
}
|
|
if b, ok := m["scores"].(bool); ok && b {
|
|
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
|
|
}
|
|
if cs, ok := m["competitions"].(string); ok && strings.TrimSpace(cs) != "" {
|
|
parts := strings.Split(cs, ",")
|
|
for _, p := range parts {
|
|
if v := strings.TrimSpace(p); v != "" {
|
|
prefs.Competitions = append(prefs.Competitions, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cacheDir := "cache/prefetch"
|
|
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
|
|
c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html})
|
|
}
|
|
|
|
// GetNewsletterPreferencesByToken returns subscriber preferences using a token (no auth required)
|
|
// GET /api/v1/newsletter/preferences?token=...
|
|
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
|
|
token := strings.TrimSpace(c.Query("token"))
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
|
return
|
|
}
|
|
|
|
emailStr, err := utils.ParseSubscriberToken(token)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
|
return
|
|
}
|
|
|
|
var sub models.NewsletterSubscription
|
|
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"email": sub.Email,
|
|
"is_active": sub.IsActive,
|
|
"preferences": sub.Preferences,
|
|
})
|
|
}
|
|
|
|
// SaveNewsletterPreferencesByToken saves subscriber preferences using a token (no auth required)
|
|
// POST /api/v1/newsletter/preferences { token, preferences }
|
|
func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
|
var input struct {
|
|
Token string `json:"token" binding:"required"`
|
|
Preferences map[string]interface{} `json:"preferences" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
|
return
|
|
}
|
|
|
|
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
|
return
|
|
}
|
|
|
|
var sub models.NewsletterSubscription
|
|
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
|
|
return
|
|
}
|
|
|
|
jm := datatypes.JSONMap{}
|
|
for key, raw := range input.Preferences {
|
|
switch v := raw.(type) {
|
|
case bool:
|
|
jm[key] = v
|
|
case string:
|
|
jm[key] = strings.TrimSpace(v)
|
|
case []interface{}:
|
|
compiled := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
|
compiled = append(compiled, trimmed)
|
|
}
|
|
}
|
|
}
|
|
jm[key] = strings.Join(compiled, ", ")
|
|
case float64, int, int64:
|
|
jm[key] = v
|
|
case nil:
|
|
jm[key] = nil
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Unsupported preference type for %s", key)})
|
|
return
|
|
}
|
|
}
|
|
|
|
if compVal, ok := jm["competitions"]; ok {
|
|
if compStr, ok := compVal.(string); ok {
|
|
comp := strings.TrimSpace(compStr)
|
|
jm["competitions"] = comp
|
|
if comp != "" {
|
|
if catVal, exists := jm["categories"]; !exists {
|
|
jm["categories"] = comp
|
|
} else if catStr, ok := catVal.(string); ok && strings.TrimSpace(catStr) == "" {
|
|
jm["categories"] = comp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sub.Preferences = jm
|
|
sub.UpdatedAt = time.Now()
|
|
if err := cc.DB.Save(&sub).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
|
|
}
|
|
|
|
// UnsubscribeByToken disables newsletter using a token (no auth required)
|
|
// POST /api/v1/newsletter/unsubscribe-token { token }
|
|
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
|
var input struct {
|
|
Token string `json:"token" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
|
return
|
|
}
|
|
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
|
return
|
|
}
|
|
if err := cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", emailStr).Update("is_active", false).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "You have been unsubscribed"})
|
|
}
|
|
|
|
// DeleteNewsletterSubscriber deletes a newsletter subscriber (admin only)
|
|
// @Summary Delete newsletter subscriber
|
|
// @Description Deletes a newsletter subscriber by ID (admin only)
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Produce json
|
|
// @Param id path int true "Subscriber ID"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/admin/newsletter/subscribers/{id} [delete]
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
|
return
|
|
}
|
|
|
|
result := cc.DB.Delete(&models.NewsletterSubscription{}, id)
|
|
if result.Error != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"})
|
|
return
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Subscriber deleted successfully"})
|
|
}
|
|
|
|
// UpdateNewsletterSubscriberStatus toggles a subscriber's active status (admin only)
|
|
// @Summary Update newsletter subscriber status
|
|
// @Description Updates the is_active status of a newsletter subscriber (admin only)
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path int true "Subscriber ID"
|
|
// @Param input body map[string]bool true "{ is_active: boolean }"
|
|
// @Success 200 {object} models.NewsletterSubscription
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/admin/newsletter/subscribers/{id}/status [patch]
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
IsActive bool `json:"is_active" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
var sub models.NewsletterSubscription
|
|
if err := cc.DB.First(&sub, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
|
}
|
|
return
|
|
}
|
|
|
|
sub.IsActive = input.IsActive
|
|
sub.UpdatedAt = time.Now()
|
|
if err := cc.DB.Save(&sub).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, sub)
|
|
}
|
|
|
|
// UpdateNewsletterSubscriberPreferences updates subscriber preferences (admin only)
|
|
// @Summary Update newsletter subscriber preferences
|
|
// @Description Updates the preferences JSON for a subscriber (admin only)
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path int true "Subscriber ID"
|
|
// @Param input body map[string]bool true "Preferences map"
|
|
// @Success 200 {object} models.NewsletterSubscription
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/admin/newsletter/subscribers/{id}/preferences [patch]
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
|
return
|
|
}
|
|
|
|
var prefs map[string]bool
|
|
if err := c.ShouldBindJSON(&prefs); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"})
|
|
return
|
|
}
|
|
|
|
var sub models.NewsletterSubscription
|
|
if err := cc.DB.First(&sub, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
|
}
|
|
return
|
|
}
|
|
|
|
// convert map[string]bool to datatypes.JSONMap
|
|
jm := datatypes.JSONMap{}
|
|
for k, v := range prefs {
|
|
jm[k] = v
|
|
}
|
|
sub.Preferences = jm
|
|
sub.UpdatedAt = time.Now()
|
|
if err := cc.DB.Save(&sub).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, sub)
|
|
}
|
|
|
|
// SendNewsletterTest sends a test newsletter email to a single recipient (admin only)
|
|
// @Summary Send test newsletter email
|
|
// @Description Sends a test newsletter email to a single recipient (admin only)
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param input body map[string]string false "Optional {email} to send test to"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Router /api/v1/admin/newsletter/test [post]
|
|
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)
|
|
|
|
// Resolve recipients (emails > email > admin)
|
|
recipients := make([]string, 0)
|
|
if len(input.Emails) > 0 {
|
|
for _, e := range input.Emails {
|
|
if v := strings.TrimSpace(e); v != "" {
|
|
recipients = append(recipients, v)
|
|
}
|
|
}
|
|
}
|
|
if len(recipients) == 0 {
|
|
if v := strings.TrimSpace(input.Email); v != "" {
|
|
recipients = append(recipients, v)
|
|
}
|
|
}
|
|
if len(recipients) == 0 {
|
|
if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" {
|
|
recipients = append(recipients, v)
|
|
}
|
|
}
|
|
if len(recipients) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"})
|
|
return
|
|
}
|
|
|
|
t := strings.ToLower(strings.TrimSpace(input.Type))
|
|
if t == "" {
|
|
t = "newsletter"
|
|
}
|
|
|
|
logger.Info("[SendNewsletterTest] type=%s recipients=%v", t, recipients)
|
|
|
|
switch t {
|
|
case "newsletter":
|
|
testHTML := `<p>Toto je testovací newsletter z Fotbal Club. Nastavení SMTP funguje.</p>`
|
|
data := &email.NewsletterData{Subject: "Test newsletter", Content: testHTML, Recipients: recipients}
|
|
logger.Debug("[SendNewsletterTest] invoking emailService.SendNewsletter for %d recipient(s)", len(recipients))
|
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
|
logger.Error("Failed to send test newsletter: %v", err)
|
|
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter", "details": err.Error()})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter"})
|
|
}
|
|
return
|
|
}
|
|
case "welcome":
|
|
for _, r := range recipients {
|
|
w := &email.NewsletterWelcomeData{Email: r, UnsubscribeLink: ""}
|
|
if err := cc.emailService.SendNewsletterWelcome(w); err != nil {
|
|
logger.Error("Failed to send welcome test to %s: %v", r, err)
|
|
}
|
|
}
|
|
case "welcome_back":
|
|
for _, r := range recipients {
|
|
w := &email.NewsletterWelcomeBackData{Email: r}
|
|
if err := cc.emailService.SendNewsletterWelcomeBack(w); err != nil {
|
|
logger.Error("Failed to send welcome back test to %s: %v", r, err)
|
|
}
|
|
}
|
|
case "setup":
|
|
// Test subscription setup email with token
|
|
for _, r := range recipients {
|
|
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
|
|
if tErr != nil {
|
|
logger.Error("Failed to generate token for setup test: %v", tErr)
|
|
continue
|
|
}
|
|
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
|
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
|
|
setupEmail := &email.EmailData{
|
|
Subject: "Test: Nastavte svůj newsletter",
|
|
To: []string{r},
|
|
Template: "newsletter_setup",
|
|
Data: struct{ SetupURL string }{SetupURL: setupURL},
|
|
}
|
|
if err := cc.emailService.SendEmail(setupEmail); err != nil {
|
|
logger.Error("Failed to send setup test to %s: %v", r, err)
|
|
}
|
|
}
|
|
case "match_reminder_48h":
|
|
// Test 48h match reminder
|
|
testHTML := `
|
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
|
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Připomínáme nadcházející zápas:</h2>
|
|
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
|
|
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test vs SK Example</h3>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> 2025-10-02</p>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
|
|
</div>
|
|
<p style="color: #4a5568; margin-top: 20px;">Zápas začíná za 48 hodin. Nezapomeňte!</p>
|
|
</div>`
|
|
data := &email.NewsletterData{Subject: "Test: Nadcházející zápas za 48 hodin", Content: testHTML, Recipients: recipients}
|
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
|
logger.Error("Failed to send match reminder test: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
|
return
|
|
}
|
|
case "match_reminder_today":
|
|
// Test day-of match reminder
|
|
testHTML := `
|
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
|
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Zápas je dnes!</h2>
|
|
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
|
|
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test vs SK Example</h3>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> Dnes</p>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
|
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
|
|
</div>
|
|
<p style="color: #4a5568; margin-top: 20px;">Přijďte fandit!</p>
|
|
</div>`
|
|
data := &email.NewsletterData{Subject: "Test: Zápas dnes", Content: testHTML, Recipients: recipients}
|
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
|
logger.Error("Failed to send match today test: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
|
return
|
|
}
|
|
case "blog_notification":
|
|
// Test blog release notification
|
|
testHTML := `
|
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
|
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
|
|
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
|
|
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">Testovací článek: Zajímavosti ze sezóny</h3>
|
|
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">Toto je ukázkový výňatek z nového článku na našem webu. Přečtěte si celý příběh a dozvíte se více zajímavostí ze sezóny.</p>
|
|
<a href="https://example.com/news/test" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
|
|
</div>
|
|
</div>`
|
|
data := &email.NewsletterData{Subject: "Test: Nový článek - Zajímavosti ze sezóny", Content: testHTML, Recipients: recipients}
|
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
|
logger.Error("Failed to send blog notification test: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
|
return
|
|
}
|
|
case "match_result":
|
|
// Test match result notification
|
|
testHTML := `
|
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
|
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Výsledek zápasu</h2>
|
|
<div style="border-left: 4px solid #d69e2e; padding: 20px; background: #fffbeb; margin: 20px 0;">
|
|
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test <span style="color: #d69e2e;">3:2</span> SK Example</h3>
|
|
<p style="color: #975a16; margin: 5px 0;"><strong>Datum:</strong> 2025-09-30</p>
|
|
<p style="color: #975a16; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
|
<p style="color: #975a16; margin: 10px 0 0 0;">Gratulujeme týmu k vítězství!</p>
|
|
</div>
|
|
</div>`
|
|
data := &email.NewsletterData{Subject: "Test: Výsledek - FC Test 3:2 SK Example", Content: testHTML, Recipients: recipients}
|
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
|
logger.Error("Failed to send match result test: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
|
return
|
|
}
|
|
// Predefined digest test types mapped to content sections
|
|
case "blogs", "events", "matches", "scores", "weekly":
|
|
prefs := services.NewsletterPrefs{
|
|
Email: recipients[0],
|
|
ContentTypes: []string{},
|
|
Competitions: []string{},
|
|
Frequency: "daily",
|
|
}
|
|
if t == "weekly" {
|
|
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
|
|
prefs.Frequency = "weekly"
|
|
} else {
|
|
prefs.ContentTypes = []string{t}
|
|
}
|
|
cacheDir := "cache/prefetch"
|
|
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
|
|
if subj == "" {
|
|
subj = "Test digest"
|
|
}
|
|
if html == "" {
|
|
html = "<p>Momentálně žádný obsah pro zvolený typ.</p>"
|
|
}
|
|
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
|
logger.Error("Failed to send digest test: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"})
|
|
return
|
|
}
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown test type"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Test email(s) sent", "recipients": recipients, "type": t})
|
|
}
|
|
|
|
func NewContactController(db *gorm.DB, emailService email.EmailService) *ContactController {
|
|
return &ContactController{
|
|
DB: db,
|
|
emailService: emailService,
|
|
}
|
|
}
|
|
|
|
type ContactFormRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Email string `json:"email" binding:"required,email"`
|
|
Subject string `json:"subject" binding:"required"`
|
|
Message string `json:"message" binding:"required"`
|
|
Source string `json:"source"` // e.g., "contact", "sponsor"
|
|
}
|
|
|
|
// SubmitContactForm handles contact form submissions
|
|
// @Summary Submit contact form
|
|
// @Description Handles contact form submissions and sends an email notification
|
|
// @Tags contact
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param input body ContactFormRequest true "Contact form data"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/contact [post]
|
|
func (cc *ContactController) SubmitContactForm(c *gin.Context) {
|
|
var input ContactFormRequest
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Normalize source field
|
|
source := strings.TrimSpace(input.Source)
|
|
if source == "" {
|
|
source = "contact"
|
|
}
|
|
|
|
// Save to database
|
|
contactMessage := models.ContactMessage{
|
|
Name: input.Name,
|
|
Email: input.Email,
|
|
Subject: input.Subject,
|
|
Message: input.Message,
|
|
Source: source,
|
|
IPAddress: c.ClientIP(),
|
|
UserAgent: c.Request.UserAgent(),
|
|
IsRead: false,
|
|
}
|
|
|
|
if err := cc.DB.Create(&contactMessage).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save contact message"})
|
|
return
|
|
}
|
|
|
|
// Send email notification asynchronously to prevent frontend timeout
|
|
go func() {
|
|
emailData := &email.ContactFormData{
|
|
Name: input.Name,
|
|
Email: input.Email,
|
|
Subject: input.Subject,
|
|
Message: input.Message,
|
|
IPAddress: c.ClientIP(),
|
|
UserAgent: c.Request.UserAgent(),
|
|
}
|
|
|
|
if err := cc.emailService.SendContactForm(emailData); err != nil {
|
|
logger.Error("Failed to send contact form email: %v", err)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Your message has been sent successfully"})
|
|
}
|
|
|
|
type NewsletterSubscriptionRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Preferences map[string]bool `json:"preferences"`
|
|
}
|
|
|
|
// SubscribeToNewsletter handles newsletter subscriptions
|
|
// @Summary Subscribe to newsletter
|
|
// @Description Handles newsletter subscription requests
|
|
// @Tags newsletter
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param input body NewsletterSubscriptionRequest true "Subscription data"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/newsletter/subscribe [post]
|
|
func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
|
|
var input NewsletterSubscriptionRequest
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check if email already exists
|
|
var subscription models.NewsletterSubscription
|
|
result := cc.DB.Where("email = ?", input.Email).First(&subscription)
|
|
|
|
if result.Error == nil {
|
|
if !subscription.IsActive {
|
|
// Reactivate existing subscription
|
|
subscription.IsActive = true
|
|
subscription.UpdatedAt = time.Now()
|
|
// Update preferences if provided (convert to JSONMap)
|
|
if input.Preferences != nil {
|
|
jm := datatypes.JSONMap{}
|
|
for k, v := range input.Preferences {
|
|
jm[k] = v
|
|
}
|
|
subscription.Preferences = jm
|
|
}
|
|
if err := cc.DB.Save(&subscription).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscription"})
|
|
return
|
|
}
|
|
|
|
// Send welcome back email in a goroutine
|
|
go func(sub models.NewsletterSubscription) {
|
|
manageURL := ""
|
|
unsubscribeURL := ""
|
|
token, tErr := utils.GenerateSubscriberToken(sub.Email, 60*24)
|
|
if tErr != nil {
|
|
logger.Error("Failed to generate subscriber token: %v", tErr)
|
|
} else {
|
|
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
|
if baseFE != "" {
|
|
link := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
|
|
manageURL = link
|
|
unsubscribeURL = link
|
|
}
|
|
}
|
|
|
|
emailData := &email.NewsletterWelcomeBackData{
|
|
Email: sub.Email,
|
|
ManageURL: manageURL,
|
|
UnsubscribeURL: unsubscribeURL,
|
|
}
|
|
if err := cc.emailService.SendNewsletterWelcomeBack(emailData); err != nil {
|
|
logger.Error("Failed to send welcome back email: %v", err)
|
|
}
|
|
}(subscription)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Welcome back! You have been resubscribed to our newsletter."})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "You are already subscribed to our newsletter"})
|
|
return
|
|
}
|
|
|
|
// Create new subscription. Default: enable everything if preferences omitted
|
|
prefs := input.Preferences
|
|
if prefs == nil {
|
|
prefs = map[string]bool{"weekly": true, "matches": true, "blogs": true, "events": true}
|
|
}
|
|
// convert to datatypes.JSONMap
|
|
jm := datatypes.JSONMap{}
|
|
for k, v := range prefs {
|
|
jm[k] = v
|
|
}
|
|
subscription = models.NewsletterSubscription{
|
|
Email: input.Email,
|
|
IsActive: true,
|
|
Preferences: jm,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := cc.DB.Create(&subscription).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe to newsletter"})
|
|
return
|
|
}
|
|
|
|
// Send setup email (link with token) AND welcome introduction email in goroutines
|
|
go func() {
|
|
// Generate token and build setup + unsubscribe URLs
|
|
token, tErr := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
|
|
if tErr != nil {
|
|
logger.Error("Failed to generate subscriber token: %v", tErr)
|
|
return
|
|
}
|
|
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
|
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
|
|
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
|
|
|
|
// 1) Setup email
|
|
setupEmail := &email.EmailData{
|
|
Subject: "Nastavte svůj newsletter",
|
|
To: []string{subscription.Email},
|
|
Template: "newsletter_setup",
|
|
Data: struct{ SetupURL string }{SetupURL: setupURL},
|
|
}
|
|
if err := cc.emailService.SendEmail(setupEmail); err != nil {
|
|
logger.Error("Failed to send setup email: %v", err)
|
|
}
|
|
|
|
// 2) Welcome introduction email (includes unsubscribe/manage link)
|
|
welcome := &email.NewsletterWelcomeData{
|
|
Email: subscription.Email,
|
|
UnsubscribeLink: unsubscribeURL,
|
|
}
|
|
if err := cc.emailService.SendNewsletterWelcome(welcome); err != nil {
|
|
logger.Error("Failed to send welcome email: %v", err)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Thank you for subscribing to our newsletter! Please check your email to confirm your subscription."})
|
|
}
|
|
|
|
// SetupNewsletterPreferences accepts a subscriber token and preferences to save choices
|
|
// ... (rest of the code remains the same)
|
|
// @Summary Setup newsletter preferences
|
|
// @Description Accepts token and preferences to save subscriber choices
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param input body map[string]interface{} true "{ token: string, preferences: { .. } }"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/newsletter/setup [post]
|
|
func (cc *ContactController) SetupNewsletterPreferences(c *gin.Context) {
|
|
var input struct {
|
|
Token string `json:"token" binding:"required"`
|
|
Preferences map[string]bool `json:"preferences" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
|
return
|
|
}
|
|
|
|
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
|
return
|
|
}
|
|
|
|
var sub models.NewsletterSubscription
|
|
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
|
|
return
|
|
}
|
|
|
|
// convert map[string]bool to datatypes.JSONMap
|
|
jm := datatypes.JSONMap{}
|
|
for k, v := range input.Preferences {
|
|
jm[k] = v
|
|
}
|
|
sub.Preferences = jm
|
|
sub.UpdatedAt = time.Now()
|
|
if err := cc.DB.Save(&sub).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
|
|
}
|
|
|
|
// GetContactMessages returns a list of contact messages (admin only)
|
|
// @Summary Get contact messages
|
|
// @Description Returns a paginated list of contact messages (admin only)
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Produce json
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param limit query int false "Items per page" default(10)
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Router /api/v1/admin/contact-messages [get]
|
|
func (cc *ContactController) GetContactMessages(c *gin.Context) {
|
|
// Check if user is admin
|
|
if c.GetString("userRole") != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
// Get pagination parameters
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
offset := (page - 1) * limit
|
|
|
|
var messages []models.ContactMessage
|
|
var total int64
|
|
|
|
// Get total count
|
|
if err := cc.DB.Model(&models.ContactMessage{}).Count(&total).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
|
|
return
|
|
}
|
|
|
|
// Get paginated messages
|
|
if err := cc.DB.Offset(offset).Limit(limit).Order("created_at DESC").Find(&messages).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"data": messages,
|
|
"pagination": gin.H{
|
|
"total": total,
|
|
"page": page,
|
|
"limit": limit,
|
|
"pages": (int(total) + limit - 1) / limit,
|
|
"has_more": offset+limit < int(total),
|
|
},
|
|
})
|
|
}
|
|
|
|
// MarkMessageAsRead marks a contact message as read (admin only)
|
|
// @Summary Mark message as read
|
|
// @Description Marks a contact message as read (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
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/admin/contact-messages/{id}/read [patch]
|
|
func (cc *ContactController) MarkMessageAsRead(c *gin.Context) {
|
|
// Check if user is admin
|
|
if c.GetString("userRole") != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
var message models.ContactMessage
|
|
if err := cc.DB.First(&message, c.Param("id")).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
|
|
return
|
|
}
|
|
|
|
message.IsRead = true
|
|
message.ReadAt = time.Now()
|
|
|
|
if err := cc.DB.Save(&message).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update message"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Message marked as read"})
|
|
}
|
|
|
|
// GetNewsletterSubscribers returns a list of newsletter subscribers (admin only)
|
|
// @Summary Get newsletter subscribers
|
|
// @Description Returns a list of all newsletter subscribers (admin only)
|
|
// @Tags admin
|
|
// @Security Bearer
|
|
// @Produce json
|
|
// @Success 200 {object} []models.NewsletterSubscription
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Router /api/v1/admin/newsletter/subscribers [get]
|
|
func (cc *ContactController) GetNewsletterSubscribers(c *gin.Context) {
|
|
if c.GetString("userRole") != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
var subscribers []models.NewsletterSubscription
|
|
if err := cc.DB.Find(&subscribers).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscribers"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, subscribers)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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": "Failed to fetch message"})
|
|
}
|
|
return
|
|
}
|
|
|
|
// Prepare email data for forwarding
|
|
forwardData := &email.EmailData{
|
|
Subject: fmt.Sprintf("Fwd: Contact Form - %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,
|
|
},
|
|
}
|
|
|
|
// Send email asynchronously
|
|
go func() {
|
|
if err := cc.emailService.SendEmail(forwardData); err != nil {
|
|
logger.Error("Failed to forward contact message %d to %s: %v", id, input.ToEmail, err)
|
|
} else {
|
|
logger.Info("Contact message %d forwarded to %s", id, input.ToEmail)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Message is being forwarded to " + input.ToEmail})
|
|
}
|
|
|
|
// ForwardAllContactMessages forwards all contact messages to a specified email (admin only)
|
|
// @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 }"
|
|
// @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" binding:"required,email"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email address is required"})
|
|
return
|
|
}
|
|
|
|
// 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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No messages to forward"})
|
|
return
|
|
}
|
|
|
|
// Forward all messages asynchronously
|
|
go func(msgs []models.ContactMessage, toEmail string) {
|
|
successCount := 0
|
|
for _, message := range msgs {
|
|
forwardData := &email.EmailData{
|
|
Subject: fmt.Sprintf("Fwd: Contact Form - %s", message.Subject),
|
|
To: []string{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, toEmail, err)
|
|
} else {
|
|
successCount++
|
|
}
|
|
}
|
|
logger.Info("Forwarded %d of %d contact messages to %s", successCount, len(msgs), toEmail)
|
|
}(messages, input.ToEmail)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": fmt.Sprintf("Forwarding %d message(s) to %s", len(messages), input.ToEmail),
|
|
"count": len(messages),
|
|
})
|
|
}
|