This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
package controllers
import (
"encoding/base64"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// EmailController handles tracking endpoints and basic analytics
type EmailController struct {
DB *gorm.DB
}
// sendUmamiEmailEvent sends a custom event to Umami if configured.
func sendUmamiEmailEvent(name, urlPath, title string, data map[string]any) {
cfg := config.AppConfig
if cfg == nil || cfg.UmamiURL == "" || cfg.UmamiWebsiteID == "" {
return
}
svc := services.NewUmamiService()
// Best-effort, ignore error
_ = svc.SendEvent(cfg.UmamiWebsiteID, name, urlPath, title, data, "email")
}
// Admin: events for a specific email log
// GET /api/v1/admin/newsletter/stats/:id/events
func (ec *EmailController) GetEmailEventsForLog(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
// Verify log exists
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "email log not found"})
return
}
var events []models.EmailEvent
if err := ec.DB.Where("email_log_id = ?", log.ID).Order("created_at ASC").Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load events"})
return
}
c.JSON(http.StatusOK, gin.H{"data": events})
}
func NewEmailController(db *gorm.DB) *EmailController {
return &EmailController{DB: db}
}
// 1x1 transparent GIF bytes
var oneByOneGIF = []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00,
0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00,
0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00,
0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44,
0x01, 0x00, 0x3b,
}
// OpenPixel records an open event and returns a transparent GIF
// GET /api/v1/email/open.gif?m=<log_id>&t=<token>
func (ec *EmailController) OpenPixel(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
if idStr == "" || tok == "" {
c.Data(http.StatusOK, "image/gif", oneByOneGIF)
return
}
id, _ := strconv.Atoi(idStr)
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
meta := models.EmailEvent{EmailLogID: log.ID, EventType: "open", Meta: map[string]any{
"ua": c.Request.UserAgent(),
"ip": c.ClientIP(),
}}
_ = ec.DB.Create(&meta).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Open", "/email/open", "Email Open", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
})
}
c.Header("Cache-Control", "no-store, must-revalidate")
c.Data(http.StatusOK, "image/gif", oneByOneGIF)
}
// ClickRedirect records a click event then redirects to the target URL
// GET /api/v1/email/click?m=<log_id>&t=<token>&u=<encoded_url>
func (ec *EmailController) ClickRedirect(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
target := c.Query("u")
if target == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing u"})
return
}
// allow both raw absolute or base64-encoded URLs
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
// ok
} else {
if decoded, err := base64.URLEncoding.DecodeString(target); err == nil {
target = string(decoded)
}
}
// Validate URL
if u, err := url.Parse(target); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
if idStr != "" && tok != "" {
if id, err := strconv.Atoi(idStr); err == nil {
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
meta := models.EmailEvent{EmailLogID: log.ID, EventType: "click", Meta: map[string]any{
"url": target,
"ua": c.Request.UserAgent(),
"ip": c.ClientIP(),
}}
_ = ec.DB.Create(&meta).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Click", "/email/click", "Email Click", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
"url": target,
})
}
}
}
c.Redirect(http.StatusFound, target)
}
// MarkSpam marks an email as spam and (optionally) deactivates the subscriber
// GET /api/v1/email/spam?m=<log_id>&t=<token>
func (ec *EmailController) MarkSpam(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
id, _ := strconv.Atoi(idStr)
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
_ = ec.DB.Create(&models.EmailEvent{EmailLogID: log.ID, EventType: "spam", Meta: map[string]any{"at": time.Now().Format(time.RFC3339)}}).Error
// try to deactivate subscription
_ = ec.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", log.RecipientEmail).Update("is_active", false).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Spam", "/email/spam", "Email Marked Spam", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Unsubscribe marks unsubscribe and deactivates the subscriber
// GET /api/v1/email/unsubscribe?m=<log_id>&t=<token>
func (ec *EmailController) Unsubscribe(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
id, _ := strconv.Atoi(idStr)
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
_ = ec.DB.Create(&models.EmailEvent{EmailLogID: log.ID, EventType: "unsubscribe", Meta: map[string]any{"at": time.Now().Format(time.RFC3339)}}).Error
_ = ec.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", log.RecipientEmail).Update("is_active", false).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Unsubscribe", "/email/unsubscribe", "Email Unsubscribe", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: recent email logs with event counts
// GET /api/v1/admin/newsletter/stats/recent
func (ec *EmailController) GetRecentEmailStats(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var logs []models.EmailLog
_ = ec.DB.Order("created_at DESC").Limit(50).Find(&logs).Error
// aggregate events per log
type Row struct {
ID uint
Opens int64
Clicks int64
Spam int64
Unsubs int64
}
rows := map[uint]*Row{}
var evs []models.EmailEvent
_ = ec.DB.Where("email_log_id IN ?", func() []uint {
ids := make([]uint, 0, len(logs))
for _, l := range logs { ids = append(ids, l.ID) }
if len(ids) == 0 { ids = []uint{0} }
return ids
}()).Find(&evs).Error
for _, e := range evs {
r := rows[e.EmailLogID]
if r == nil { r = &Row{ID: e.EmailLogID}; rows[e.EmailLogID] = r }
switch strings.ToLower(e.EventType) {
case "open": r.Opens++
case "click": r.Clicks++
case "spam": r.Spam++
case "unsubscribe": r.Unsubs++
}
}
// build response
out := make([]gin.H, 0, len(logs))
for _, l := range logs {
r := rows[l.ID]
var opens, clicks, spam, unsubs int64
if r != nil {
opens, clicks, spam, unsubs = r.Opens, r.Clicks, r.Spam, r.Unsubs
}
out = append(out, gin.H{
"id": l.ID,
"created_at": l.CreatedAt,
"subject": l.Subject,
"recipient": l.RecipientEmail,
"type": l.Type,
"status": l.Status,
"opens": opens,
"clicks": clicks,
"spam": spam,
"unsubs": unsubs,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}