package controllers import ( "crypto/rand" "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 } // GetNewsletterTokenForUser returns a short-lived newsletter preferences token for the authenticated user's email // 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 } // Generate a 24h token for managing newsletter preferences token, err := utils.GenerateSubscriberToken(email, 60*24) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) return } c.JSON(http.StatusOK, gin.H{"token": token}) } // SendNewsletterDigest builds and sends a digest newsletter based on a template type (admin only) // POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" } 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 := `
Toto je testovací newsletter z Fotbal Club. Nastavení SMTP funguje.
` 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 := `Datum: 2025-10-02
Čas: 17:00
Soutěž: MFS A
Místo: Sportovní areál Test
Zápas začíná za 48 hodin. Nezapomeňte!
Datum: Dnes
Čas: 17:00
Soutěž: MFS A
Místo: Sportovní areál Test
Přijďte fandit!
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.
Číst článekDatum: 2025-09-30
Soutěž: MFS A
Gratulujeme týmu k vítězství!
Momentálně žádný obsah pro zvolený typ.
" } 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 } // Generate a subscriber token to include in follow-up emails (preferences links) token, _ := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token) unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token) // Auto-create fan user account if not exists var existingUser models.User if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&existingUser).Error; err == gorm.ErrRecordNotFound { // Generate a strong random password (16 chars, mixed set) genPassword := func(n int) string { const letters = "abcdefghijklmnopqrstuvwxyz" const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" const digits = "0123456789" const symbols = "!@#$%^&*()-_=+[]{}?" pool := letters + upper + digits + symbols b := make([]byte, n) for i := 0; i < n; i++ { var rb [1]byte if _, err := rand.Read(rb[:]); err != nil { b[i] = pool[(time.Now().UnixNano()+int64(i))%int64(len(pool))] continue } b[i] = pool[int(rb[0])%len(pool)] } return string(b) } plain := genPassword(16) hashed, hErr := utils.HashPassword(plain) if hErr == nil { u := models.User{ Email: strings.TrimSpace(strings.ToLower(subscription.Email)), Password: hashed, FirstName: "", LastName: "", Role: "fan", IsActive: true, } if err := cc.DB.Create(&u).Error; err != nil { logger.Error("Failed to auto-create fan user for newsletter: %v", err) } else { // Send credentials email data := map[string]interface{}{ "Email": subscription.Email, "Password": plain, "LoginURL": baseFE + "/login", "ResetURL": baseFE + "/forgot-password", "ManageURL": setupURL, "UnsubscribeURL": unsubscribeURL, } credEmail := &email.EmailData{ Subject: "Váš fan účet byl vytvořen", To: []string{subscription.Email}, Template: "fan_account_created", Data: data, } if err := cc.emailService.SendEmail(credEmail); err != nil { logger.Error("Failed to send fan account created email: %v", err) } } } } // Send setup email (link with token) AND welcome introduction email in goroutines go func() { // 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), }) }