mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
245 lines
8.7 KiB
Go
245 lines
8.7 KiB
Go
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})
|
|
}
|