mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #100 - WE ARE FUCKING DONE, hotfixes incoming but we did it in 100 days, lets fucking go guys, anyone reading this...i love you
This commit is contained in:
@@ -71,6 +71,7 @@ type Config struct {
|
||||
ScraperBaseURL string
|
||||
FrontendBaseURL string
|
||||
PublicAPIBaseURL string
|
||||
ZoneramaAPIBase string
|
||||
|
||||
// Umami Analytics
|
||||
UmamiURL string
|
||||
@@ -181,6 +182,7 @@ func LoadConfig() {
|
||||
ScraperBaseURL: getEnv("FACR_SCRAPER_BASE_URL", "http://localhost:8081"),
|
||||
FrontendBaseURL: getEnv("FRONTEND_BASE_URL", "http://localhost:3000"),
|
||||
PublicAPIBaseURL: getEnv("PUBLIC_API_BASE_URL", "http://localhost:8080/api/v1"),
|
||||
ZoneramaAPIBase: getEnv("ZONERAMA_API_BASE", "https://zonerama.tdvorak.dev"),
|
||||
|
||||
// Umami Analytics
|
||||
UmamiURL: getEnv("UMAMI_URL", ""),
|
||||
|
||||
@@ -567,6 +567,9 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
|
||||
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
|
||||
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
|
||||
}
|
||||
if ov.ScoreOverride != nil {
|
||||
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
|
||||
}
|
||||
if ov.HomeLogoURL != nil {
|
||||
m["home_logo_url"] = *ov.HomeLogoURL
|
||||
}
|
||||
@@ -689,6 +692,9 @@ func (bc *BaseController) GetMatches(c *gin.Context) {
|
||||
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
|
||||
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
|
||||
}
|
||||
if ov.ScoreOverride != nil {
|
||||
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
|
||||
}
|
||||
if ov.HomeLogoURL != nil {
|
||||
m["home_logo_url"] = *ov.HomeLogoURL
|
||||
}
|
||||
@@ -901,7 +907,8 @@ func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) {
|
||||
photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24"))
|
||||
rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true"))
|
||||
// Build external URL
|
||||
api := "https://zonerama.tdvorak.dev/zonerama-album?link=" + url.QueryEscape(link)
|
||||
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
|
||||
api := base + "/zonerama-album?link=" + url.QueryEscape(link)
|
||||
if photoLimit != "" {
|
||||
api += "&photo_limit=" + url.QueryEscape(photoLimit)
|
||||
}
|
||||
@@ -2471,30 +2478,18 @@ func (bc *BaseController) PutMatchOverride(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// PatchMatchOverride partially updates fields of an override by external_match_id
|
||||
func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
|
||||
extID := c.Param("external_match_id")
|
||||
if extID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_match_id"})
|
||||
return
|
||||
}
|
||||
var item models.MatchOverride
|
||||
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Prevent changing the key
|
||||
delete(body, "external_match_id")
|
||||
// Normalize date_time_override to *time.Time if provided as string
|
||||
if v, ok := body["date_time_override"]; ok {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
@@ -2513,6 +2508,23 @@ func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var item models.MatchOverride
|
||||
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
attrs := map[string]interface{}{"external_match_id": extID}
|
||||
for k, v := range body {
|
||||
attrs[k] = v
|
||||
}
|
||||
if err := bc.DB.Where("external_match_id = ?", extID).Assign(attrs).FirstOrCreate(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
||||
return
|
||||
}
|
||||
if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
|
||||
return
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
@@ -166,14 +165,37 @@ func (cc *CommentController) React(c *gin.Context) {
|
||||
}
|
||||
|
||||
uidv, _ := c.Get("userID")
|
||||
userID := uidv.(uint)
|
||||
var userID uint
|
||||
switch v := uidv.(type) {
|
||||
case uint:
|
||||
userID = v
|
||||
case int:
|
||||
if v > 0 { userID = uint(v) }
|
||||
case int64:
|
||||
if v > 0 { userID = uint(v) }
|
||||
case float64:
|
||||
if v > 0 { userID = uint(v) }
|
||||
case string:
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
|
||||
}
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Atomic upsert: enforce single reaction per (comment_id, user_id)
|
||||
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
|
||||
if err := cc.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{"type": rt, "updated_at": time.Now()}),
|
||||
}).Create(&r).Error; err != nil {
|
||||
// Robust upsert without relying on a DB unique constraint: delete then insert in a transaction
|
||||
if err := cc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Remove any previous reaction by this user on this comment
|
||||
if err := tx.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Insert the new reaction
|
||||
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
|
||||
if err := tx.Create(&r).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
|
||||
return
|
||||
}
|
||||
@@ -194,7 +216,24 @@ func (cc *CommentController) Unreact(c *gin.Context) {
|
||||
// Ensure reactions table exists (best-effort)
|
||||
_ = cc.DB.AutoMigrate(&models.CommentReaction{})
|
||||
uidv, _ := c.Get("userID")
|
||||
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uidv.(uint)).Delete(&models.CommentReaction{}).Error
|
||||
var userID uint
|
||||
switch v := uidv.(type) {
|
||||
case uint:
|
||||
userID = v
|
||||
case int:
|
||||
if v > 0 { userID = uint(v) }
|
||||
case int64:
|
||||
if v > 0 { userID = uint(v) }
|
||||
case float64:
|
||||
if v > 0 { userID = uint(v) }
|
||||
case string:
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
|
||||
}
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,28 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
|
||||
s.NewsletterWeeklyHour = 9
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Auto-activate match reminders with sane defaults if not configured
|
||||
if !s.EnableMatchReminders {
|
||||
s.EnableMatchReminders = true
|
||||
changed = true
|
||||
}
|
||||
if s.NewsletterReminderLeadHours <= 0 {
|
||||
s.NewsletterReminderLeadHours = 48 // 48h before kickoff
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Auto-activate match results notifications and default quiet hours if missing
|
||||
if !s.EnableResults {
|
||||
s.EnableResults = true
|
||||
changed = true
|
||||
}
|
||||
// Only set quiet hours if both are unset (0,0) to avoid overriding admin-configured values
|
||||
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
|
||||
s.NewsletterQuietStart = 22 // 22:00
|
||||
s.NewsletterQuietEnd = 8 // 08:00
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if s.ID == 0 {
|
||||
_ = cc.DB.Create(&s).Error
|
||||
@@ -511,12 +533,6 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Additionally, send a minimal confirmation using newsletter template with manage link (best-effort)
|
||||
_ = cc.emailService.SendNewsletter(&email.NewsletterData{
|
||||
Subject: "Vítejte v odběru",
|
||||
Content: fmt.Sprintf("<p>Děkujeme za přihlášení. Spravujte své preference <a href=\"%s\">zde</a>.</p>", manageURL),
|
||||
Recipients: []string{emailStr},
|
||||
})
|
||||
// Recalculate automation after (re)subscription
|
||||
cc.recalcNewsletterAutomationEnabled()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Subscribed"})
|
||||
@@ -700,21 +716,89 @@ func (cc *ContactController) SubmitContactForm(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
go func(nm, em, subj, msgBody, ipAddr, agent string) {
|
||||
go func(m models.ContactMessage) {
|
||||
// 1) Notify primary contact(s) (club contact email / env fallbacks)
|
||||
_ = cc.emailService.SendContactForm(&email.ContactFormData{
|
||||
Name: nm,
|
||||
Email: em,
|
||||
Subject: subj,
|
||||
Message: msgBody,
|
||||
IPAddress: ipAddr,
|
||||
UserAgent: agent,
|
||||
Name: m.Name,
|
||||
Email: m.Email,
|
||||
Subject: m.Subject,
|
||||
Message: m.Message,
|
||||
IPAddress: m.IPAddress,
|
||||
UserAgent: m.UserAgent,
|
||||
})
|
||||
}(name, emailStr, subject, message, ip, ua)
|
||||
|
||||
// 2) Auto-forward to configured list when enabled
|
||||
var set models.Settings
|
||||
if err := cc.DB.First(&set).Error; err == nil && set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" {
|
||||
// Build recipient list from ContactForwardList (comma/semicolon/space separated)
|
||||
parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
|
||||
uniq := make(map[string]struct{})
|
||||
dest := make([]string, 0, len(parts))
|
||||
// Exclude addresses that already received the primary notification (contact/admin emails)
|
||||
exclude := map[string]struct{}{}
|
||||
if v := strings.ToLower(strings.TrimSpace(set.ContactEmail)); v != "" {
|
||||
exclude[v] = struct{}{}
|
||||
}
|
||||
if config.AppConfig != nil {
|
||||
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.ContactEmail)); v != "" {
|
||||
exclude[v] = struct{}{}
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.AdminEmail)); v != "" {
|
||||
exclude[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
lv := strings.ToLower(v)
|
||||
if _, ok := uniq[lv]; ok {
|
||||
continue
|
||||
}
|
||||
if _, skip := exclude[lv]; skip {
|
||||
continue
|
||||
}
|
||||
uniq[lv] = struct{}{}
|
||||
dest = append(dest, v)
|
||||
}
|
||||
if len(dest) > 0 {
|
||||
fwd := &email.EmailData{
|
||||
Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", strings.TrimSpace(m.Subject)),
|
||||
To: dest,
|
||||
Template: "contact_form",
|
||||
Data: struct {
|
||||
Name string
|
||||
Email string
|
||||
Subject string
|
||||
Message string
|
||||
Time string
|
||||
IP string
|
||||
Agent string
|
||||
}{
|
||||
Name: m.Name,
|
||||
Email: m.Email,
|
||||
Subject: m.Subject,
|
||||
Message: m.Message,
|
||||
Time: m.CreatedAt.Format(time.RFC1123Z),
|
||||
IP: m.IPAddress,
|
||||
Agent: m.UserAgent,
|
||||
},
|
||||
}
|
||||
if err := cc.emailService.SendEmail(fwd); err != nil {
|
||||
logger.Error("Auto-forward of contact message %d failed: %v", m.ID, err)
|
||||
} else {
|
||||
logger.Info("Auto-forwarded contact message %d to %v", m.ID, dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(msg)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
|
||||
}
|
||||
|
||||
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
|
||||
// ... rest of the code remains the same ...
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
@@ -904,6 +988,33 @@ func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
|
||||
s = models.Settings{}
|
||||
}
|
||||
s.NewsletterEnabled = input.Enabled
|
||||
|
||||
// If enabling, ensure defaults for weekly/matches/results are set like auto-recalc does
|
||||
if input.Enabled {
|
||||
if !s.EnableWeekly {
|
||||
s.EnableWeekly = true
|
||||
}
|
||||
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
|
||||
s.NewsletterWeeklyDay = "sun"
|
||||
}
|
||||
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
|
||||
s.NewsletterWeeklyHour = 9
|
||||
}
|
||||
if !s.EnableMatchReminders {
|
||||
s.EnableMatchReminders = true
|
||||
}
|
||||
if s.NewsletterReminderLeadHours <= 0 {
|
||||
s.NewsletterReminderLeadHours = 48
|
||||
}
|
||||
if !s.EnableResults {
|
||||
s.EnableResults = true
|
||||
}
|
||||
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
|
||||
s.NewsletterQuietStart = 22
|
||||
s.NewsletterQuietEnd = 8
|
||||
}
|
||||
}
|
||||
|
||||
if s.ID == 0 {
|
||||
if err := cc.DB.Create(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
@@ -149,9 +151,10 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
|
||||
body.PhotoLimit = 50 // Default to 50 photos per album
|
||||
}
|
||||
|
||||
// Call external API
|
||||
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
|
||||
body.Link, body.PhotoLimit)
|
||||
// Call external API (configurable base)
|
||||
apiBase := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
|
||||
apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
|
||||
apiBase, url.QueryEscape(body.Link), body.PhotoLimit)
|
||||
|
||||
logger.Info("Fetching album from Zonerama API: %s", apiURL)
|
||||
|
||||
@@ -242,13 +245,13 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
|
||||
}
|
||||
|
||||
logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos))
|
||||
|
||||
|
||||
// Regenerate flat gallery files for frontend consumption
|
||||
if err := services.RegenerateFlatGalleryFiles(); err != nil {
|
||||
logger.Error("Failed to regenerate flat gallery files: %v", err)
|
||||
// Don't fail the request, just log the error
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Album fetched and saved successfully",
|
||||
"album": albumData,
|
||||
@@ -300,13 +303,13 @@ func (gc *GalleryController) DeleteAlbum(c *gin.Context) {
|
||||
}
|
||||
|
||||
logger.Info("Deleted album: %s", albumID)
|
||||
|
||||
|
||||
// Regenerate flat gallery files for frontend consumption
|
||||
if err := services.RegenerateFlatGalleryFiles(); err != nil {
|
||||
logger.Error("Failed to regenerate flat gallery files: %v", err)
|
||||
// Don't fail the request, just log the error
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"})
|
||||
}
|
||||
|
||||
@@ -316,27 +319,27 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) {
|
||||
var settings struct {
|
||||
GalleryURL string `json:"gallery_url"`
|
||||
}
|
||||
|
||||
|
||||
if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil {
|
||||
logger.Error("Failed to load settings: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
zoneramaURL := strings.TrimSpace(settings.GalleryURL)
|
||||
if zoneramaURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Validate it's a Zonerama URL
|
||||
if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL)
|
||||
|
||||
|
||||
// Call the refresh service in a goroutine to avoid blocking
|
||||
go func() {
|
||||
if err := services.RefreshZoneramaNow(zoneramaURL); err != nil {
|
||||
@@ -349,7 +352,7 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Zonerama refresh started",
|
||||
"url": zoneramaURL,
|
||||
|
||||
@@ -262,6 +262,11 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
||||
updates["requires_admin"] = b
|
||||
}
|
||||
}
|
||||
if v, ok := raw["allow_editor"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 {
|
||||
updates["allow_editor"] = b
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
// Nothing to update
|
||||
@@ -372,6 +377,72 @@ func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, links)
|
||||
}
|
||||
|
||||
// GetEditorAllowedAdminNav returns admin navigation items that are explicitly allowed for editors
|
||||
// Top-level items are included only when:
|
||||
// - type != dropdown and allow_editor = true (and visible = true), or
|
||||
// - type == dropdown and it has at least one child with allow_editor = true (and visible = true)
|
||||
//
|
||||
// Children are filtered to allow_editor = true and visible = true
|
||||
func (nc *NavigationController) GetEditorAllowedAdminNav(c *gin.Context) {
|
||||
var top []models.NavigationItem
|
||||
// Load all top-level admin items (categories and direct items)
|
||||
if err := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND visible = ?", true, true).
|
||||
Order("display_order ASC").
|
||||
Preload("Children", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("requires_admin = ? AND visible = ? AND allow_editor = ?", true, true, true).Order("display_order ASC")
|
||||
}).
|
||||
Find(&top).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch editor navigation"})
|
||||
return
|
||||
}
|
||||
|
||||
// Filter according to allow_editor rules
|
||||
out := make([]models.NavigationItem, 0, len(top))
|
||||
// Only allow a curated set of admin pages that have editor-capable APIs
|
||||
allowed := map[string]bool{
|
||||
"articles": true,
|
||||
"activities": true,
|
||||
"shortlinks": true,
|
||||
}
|
||||
for i := range top {
|
||||
it := top[i]
|
||||
include := false
|
||||
if it.Type == models.NavTypeDropdown {
|
||||
// Filter children by page_type allow-list (children already have allow_editor=true from preload)
|
||||
if len(it.Children) > 0 {
|
||||
children := make([]models.NavigationItem, 0, len(it.Children))
|
||||
for _, ch := range it.Children {
|
||||
if allowed[ch.PageType] {
|
||||
// ensure URL is set
|
||||
if ch.URL == "" {
|
||||
ch.URL = ch.GetURL()
|
||||
}
|
||||
children = append(children, ch)
|
||||
}
|
||||
}
|
||||
it.Children = children
|
||||
if len(it.Children) > 0 {
|
||||
include = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// direct admin page: include only when marked allow_editor
|
||||
if it.AllowEditor && allowed[it.PageType] {
|
||||
include = true
|
||||
}
|
||||
}
|
||||
if include {
|
||||
// Ensure URLs are computed
|
||||
if it.URL == "" {
|
||||
it.URL = it.GetURL()
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetAllSocialLinks returns all social links including hidden ones (admin only)
|
||||
// @Summary Get all social links (admin)
|
||||
// @Description Returns all social links for admin management
|
||||
@@ -593,7 +664,12 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
|
||||
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
|
||||
pid := parent.ID
|
||||
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
|
||||
allowEditor := false
|
||||
switch pageType {
|
||||
case "articles", "activities", "shortlinks":
|
||||
allowEditor = true
|
||||
}
|
||||
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true, AllowEditor: allowEditor}
|
||||
child.ParentID = &pid
|
||||
return tx.Create(child).Error
|
||||
}
|
||||
|
||||
@@ -29,66 +29,68 @@ type ShortLinkController struct {
|
||||
// Restrictions: only allows shortening links pointing to this site (request host)
|
||||
// or to the configured FrontendBaseURL. Intended for visitor share/copy flows.
|
||||
func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) {
|
||||
var body struct {
|
||||
TargetURL string `json:"target_url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
target, err := parseTarget(body.TargetURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||
return
|
||||
}
|
||||
tu, _ := url.Parse(target)
|
||||
if tu == nil || tu.Host == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||
return
|
||||
}
|
||||
// Allow only same-site or configured frontend host
|
||||
reqHost := c.Request.Host
|
||||
stripPort := func(h string) string {
|
||||
if i := strings.IndexByte(h, ':'); i >= 0 { return h[:i] }
|
||||
return h
|
||||
}
|
||||
allowed := stripPort(tu.Host) == stripPort(reqHost)
|
||||
if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" {
|
||||
if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" {
|
||||
if stripPort(fu.Host) == stripPort(tu.Host) {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TargetURL string `json:"target_url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
target, err := parseTarget(body.TargetURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||
return
|
||||
}
|
||||
tu, _ := url.Parse(target)
|
||||
if tu == nil || tu.Host == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||
return
|
||||
}
|
||||
// Allow only same-site or configured frontend host
|
||||
reqHost := c.Request.Host
|
||||
stripPort := func(h string) string {
|
||||
if i := strings.IndexByte(h, ':'); i >= 0 {
|
||||
return h[:i]
|
||||
}
|
||||
return h
|
||||
}
|
||||
allowed := stripPort(tu.Host) == stripPort(reqHost)
|
||||
if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" {
|
||||
if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" {
|
||||
if stripPort(fu.Host) == stripPort(tu.Host) {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Deterministic code from URL so repeated calls return same shortlink
|
||||
code := "p-" + codeFromHash(target, 7)
|
||||
link := models.ShortLink{
|
||||
Code: code,
|
||||
TargetURL: target,
|
||||
Title: strings.TrimSpace(body.Title),
|
||||
Active: true,
|
||||
}
|
||||
if err := s.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "code"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}),
|
||||
}).Create(&link).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
var saved models.ShortLink
|
||||
if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil {
|
||||
saved = link
|
||||
}
|
||||
scheme := getScheme(c)
|
||||
host := c.Request.Host
|
||||
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code)
|
||||
c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved})
|
||||
// Deterministic code from URL so repeated calls return same shortlink
|
||||
code := "p-" + codeFromHash(target, 7)
|
||||
link := models.ShortLink{
|
||||
Code: code,
|
||||
TargetURL: target,
|
||||
Title: strings.TrimSpace(body.Title),
|
||||
Active: true,
|
||||
}
|
||||
if err := s.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "code"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}),
|
||||
}).Create(&link).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
var saved models.ShortLink
|
||||
if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil {
|
||||
saved = link
|
||||
}
|
||||
scheme := getScheme(c)
|
||||
host := c.Request.Host
|
||||
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code)
|
||||
c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved})
|
||||
}
|
||||
|
||||
func NewShortLinkController(db *gorm.DB) *ShortLinkController {
|
||||
@@ -125,7 +127,9 @@ func hashIPShort(ip string) string {
|
||||
}
|
||||
|
||||
func codeFromHash(s string, n int) string {
|
||||
if n <= 0 { n = 7 }
|
||||
if n <= 0 {
|
||||
n = 7
|
||||
}
|
||||
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
out := make([]byte, n)
|
||||
@@ -137,20 +141,24 @@ func codeFromHash(s string, n int) string {
|
||||
}
|
||||
|
||||
func sanitizeCode(in string) string {
|
||||
s := strings.TrimSpace(in)
|
||||
if s == "" { return "" }
|
||||
// filter allowed runes
|
||||
rb := make([]rune, 0, len(s))
|
||||
for _, ch := range s {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
|
||||
rb = append(rb, ch)
|
||||
}
|
||||
}
|
||||
if len(rb) == 0 { return "" }
|
||||
if len(rb) > 16 {
|
||||
rb = rb[:16]
|
||||
}
|
||||
return string(rb)
|
||||
s := strings.TrimSpace(in)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
// filter allowed runes
|
||||
rb := make([]rune, 0, len(s))
|
||||
for _, ch := range s {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
|
||||
rb = append(rb, ch)
|
||||
}
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(rb) > 16 {
|
||||
rb = rb[:16]
|
||||
}
|
||||
return string(rb)
|
||||
}
|
||||
|
||||
func getScheme(c *gin.Context) string {
|
||||
@@ -174,11 +182,20 @@ func parseTarget(raw string) (string, error) {
|
||||
raw = string(dec)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", errors.New("invalid url")
|
||||
// Try as-is first
|
||||
if u, err := url.Parse(raw); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
|
||||
return u.String(), nil
|
||||
}
|
||||
return u.String(), nil
|
||||
// If scheme is missing, try https:// fallback, then http://
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
if u, err := url.Parse("https://" + raw); err == nil && u.Host != "" {
|
||||
return u.String(), nil
|
||||
}
|
||||
if u, err := url.Parse("http://" + raw); err == nil && u.Host != "" {
|
||||
return u.String(), nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("invalid url")
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) RedirectShort(c *gin.Context) {
|
||||
@@ -274,23 +291,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
code := sanitizeCode(strings.TrimSpace(body.Code))
|
||||
if code == "" {
|
||||
for i := 0; i < 5; i++ {
|
||||
cnd, _ := randCode(7)
|
||||
var cnt int64
|
||||
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
code = cnd
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
for i := 0; i < 5; i++ {
|
||||
cnd, _ := randCode(7)
|
||||
var cnt int64
|
||||
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
code = cnd
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if body.Active != nil { active = *body.Active }
|
||||
if body.Active != nil {
|
||||
active = *body.Active
|
||||
}
|
||||
link := models.ShortLink{
|
||||
Code: code,
|
||||
TargetURL: target,
|
||||
@@ -329,22 +348,37 @@ func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
|
||||
|
||||
func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return }
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"})
|
||||
return
|
||||
}
|
||||
var link models.ShortLink
|
||||
if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return }
|
||||
start := time.Now().AddDate(0,0,-30)
|
||||
type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` }
|
||||
if err := s.DB.First(&link, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
start := time.Now().AddDate(0, 0, -30)
|
||||
type Row struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var rows []Row
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("DATE(created_at)").Order("date ASC").Scan(&rows)
|
||||
var refRows []struct{ Referrer string; Count int64 }
|
||||
var refRows []struct {
|
||||
Referrer string
|
||||
Count int64
|
||||
}
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("referrer, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("referrer").Order("count DESC").Limit(20).Scan(&refRows)
|
||||
var utmRows []struct{ Source, Medium, Campaign string; Count int64 }
|
||||
var utmRows []struct {
|
||||
Source, Medium, Campaign string
|
||||
Count int64
|
||||
}
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,7 +103,7 @@ func GetRewardTypeDisplayName(rewardType string) string {
|
||||
"avatar_upload_unlock": "Odemknutí vlastního avataru",
|
||||
"merch_coupon": "Slevový kupon",
|
||||
"merch_physical": "Fyzické zboží",
|
||||
"merch_digital": "Digitální produkt",
|
||||
"merch_digital": "Digitální odměna",
|
||||
"custom": "Vlastní",
|
||||
}
|
||||
if name, ok := names[rewardType]; ok {
|
||||
|
||||
+58
-59
@@ -88,17 +88,17 @@ type Article struct {
|
||||
OGImageURL string `json:"og_image_url"`
|
||||
// Optional: link to external content or embedded media
|
||||
ExternalLink string `json:"external_link"`
|
||||
ViewCount int `gorm:"default:0;index" json:"view_count"`
|
||||
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
|
||||
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
|
||||
ViewCount int `gorm:"default:0;index" json:"view_count"`
|
||||
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
|
||||
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
|
||||
// Store the category name directly to simplify queries (denormalized)
|
||||
CategoryName string `json:"category_name"`
|
||||
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
|
||||
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
|
||||
// Gallery association (optional)
|
||||
GalleryAlbumID string `json:"gallery_album_id"`
|
||||
GalleryAlbumURL string `json:"gallery_album_url"`
|
||||
GalleryAlbumID string `json:"gallery_album_id"`
|
||||
GalleryAlbumURL string `json:"gallery_album_url"`
|
||||
// Stored as JSON string or comma-separated list; frontend normalizes
|
||||
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
|
||||
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
|
||||
// YouTube video association (optional)
|
||||
YouTubeVideoID string `json:"youtube_video_id"`
|
||||
YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"`
|
||||
@@ -108,10 +108,10 @@ type Article struct {
|
||||
// Removed omitempty to always include in JSON (even if null)
|
||||
MatchLink *ArticleMatchLink `gorm:"-" json:"match_link"`
|
||||
// Computed helpers (not persisted)
|
||||
CategorySlug string `gorm:"-" json:"category_slug,omitempty"`
|
||||
CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"`
|
||||
NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"`
|
||||
URL string `gorm:"-" json:"url,omitempty"`
|
||||
CategorySlug string `gorm:"-" json:"category_slug,omitempty"`
|
||||
CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"`
|
||||
NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"`
|
||||
URL string `gorm:"-" json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ArticleTeamLink represents a link from an article to a team identified by an external FACR ID
|
||||
@@ -143,7 +143,7 @@ type Team struct {
|
||||
ShortName string
|
||||
Description string
|
||||
LogoURL string `json:"logo_url"`
|
||||
IsActive bool `gorm:"default:true"`
|
||||
IsActive bool `gorm:"default:true"`
|
||||
}
|
||||
|
||||
// Player represents a football player
|
||||
@@ -184,15 +184,15 @@ type Sponsor struct {
|
||||
|
||||
// VideoTitleOverride represents a per-video title override (for auto YouTube source)
|
||||
type VideoTitleOverride struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// CustomNavLink represents a simple custom navigation link stored in settings.custom_nav
|
||||
type CustomNavLink struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
External bool `json:"external"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
External bool `json:"external"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
@@ -257,7 +257,7 @@ type Settings struct {
|
||||
// FrontendBaseURL: e.g. https://club.example.com
|
||||
FrontendBaseURL string `json:"frontend_base_url"`
|
||||
// APIBaseURL: full API root, e.g. https://api.example.com/api/v1 or https://backend.example.com/api/v1
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
|
||||
// Social profiles
|
||||
FacebookURL string `json:"facebook_url"`
|
||||
@@ -279,10 +279,10 @@ type Settings struct {
|
||||
VideosItemsJSON string `gorm:"type:text" json:"-"`
|
||||
|
||||
// Title overrides for auto-fetched videos (stored as JSON array of {video_id,title})
|
||||
VideosOverridesJSON string `gorm:"type:text" json:"-"`
|
||||
VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"`
|
||||
VideosOverridesJSON string `gorm:"type:text" json:"-"`
|
||||
VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"`
|
||||
// Derived helper for API responses (map form used by frontend/admin): video_id -> title
|
||||
VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"`
|
||||
VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"`
|
||||
|
||||
// Merch module configuration
|
||||
MerchModuleEnabled bool `json:"merch_module_enabled"`
|
||||
@@ -313,25 +313,25 @@ type Settings struct {
|
||||
NewsletterQuietEnd int `json:"newsletter_quiet_end"` // 0-23
|
||||
|
||||
// Contact/Location information for map
|
||||
ContactAddress string `json:"contact_address"`
|
||||
ContactCity string `json:"contact_city"`
|
||||
ContactZip string `json:"contact_zip"`
|
||||
ContactCountry string `json:"contact_country"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
ContactAddress string `json:"contact_address"`
|
||||
ContactCity string `json:"contact_city"`
|
||||
ContactZip string `json:"contact_zip"`
|
||||
ContactCountry string `json:"contact_country"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
// Contact form auto-forwarding
|
||||
ContactForwardEnabled bool `json:"contact_forward_enabled"`
|
||||
ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails
|
||||
LocationLatitude float64 `json:"location_latitude"`
|
||||
LocationLongitude float64 `json:"location_longitude"`
|
||||
MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"`
|
||||
MapStyle string `json:"map_style"`
|
||||
ShowMapOnHomepage bool `json:"show_map_on_homepage"`
|
||||
ContactForwardEnabled bool `json:"contact_forward_enabled"`
|
||||
ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails
|
||||
LocationLatitude float64 `json:"location_latitude"`
|
||||
LocationLongitude float64 `json:"location_longitude"`
|
||||
MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"`
|
||||
MapStyle string `json:"map_style"`
|
||||
ShowMapOnHomepage bool `json:"show_map_on_homepage"`
|
||||
|
||||
// Homepage matches display configuration
|
||||
FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"`
|
||||
StorageQuotaMB int `json:"storage_quota_mb"`
|
||||
StorageWarnThreshold int `json:"storage_warn_threshold"`
|
||||
StorageQuotaMB int `json:"storage_quota_mb"`
|
||||
StorageWarnThreshold int `json:"storage_warn_threshold"`
|
||||
StorageCriticalThreshold int `json:"storage_critical_threshold"`
|
||||
|
||||
// External error-review integration
|
||||
@@ -345,7 +345,6 @@ type Settings struct {
|
||||
// TableName specifies table name for Settings model
|
||||
func (Settings) TableName() string { return "settings" }
|
||||
|
||||
|
||||
// LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string.
|
||||
func (s *Settings) LoadCustomNav() {
|
||||
if s.CustomNavJSON == "" {
|
||||
@@ -416,14 +415,14 @@ func (Club) TableName() string {
|
||||
|
||||
// ContactCategory represents a category for organizing contacts (e.g., "Management", "Coaches", "Office")
|
||||
type ContactCategory struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
Name string `gorm:"not null;uniqueIndex" json:"name"`
|
||||
Description string `json:"description"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
Name string `gorm:"not null;uniqueIndex" json:"name"`
|
||||
Description string `json:"description"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the ContactCategory model
|
||||
@@ -433,20 +432,20 @@ func (ContactCategory) TableName() string {
|
||||
|
||||
// Contact represents a contact person (e.g., coach, manager, office staff)
|
||||
type Contact struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
|
||||
Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Position string `json:"position"` // e.g., "Head Coach", "President"
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
|
||||
Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Position string `json:"position"` // e.g., "Head Coach", "President"
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the Contact model
|
||||
|
||||
@@ -17,21 +17,24 @@ const (
|
||||
// NavigationItem represents a single navigation menu item
|
||||
type NavigationItem struct {
|
||||
gorm.Model
|
||||
Label string `gorm:"not null" json:"label"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"`
|
||||
PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar'
|
||||
PageID *uint `json:"page_id,omitempty"` // optional reference to specific content
|
||||
Visible bool `gorm:"not null;default:true" json:"visible"`
|
||||
DisplayOrder int `gorm:"not null;default:0" json:"display_order"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
Target string `gorm:"default:'_self'" json:"target"` // _self or _blank
|
||||
CSSClass string `json:"css_class,omitempty"`
|
||||
RequiresAuth bool `gorm:"default:false" json:"requires_auth"`
|
||||
RequiresAdmin bool `gorm:"default:false" json:"requires_admin"`
|
||||
Label string `gorm:"not null" json:"label"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"`
|
||||
PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar'
|
||||
PageID *uint `json:"page_id,omitempty"` // optional reference to specific content
|
||||
Visible bool `gorm:"not null;default:true" json:"visible"`
|
||||
DisplayOrder int `gorm:"not null;default:0" json:"display_order"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
Target string `gorm:"default:'_self'" json:"target"` // _self or _blank
|
||||
CSSClass string `json:"css_class,omitempty"`
|
||||
RequiresAuth bool `gorm:"default:false" json:"requires_auth"`
|
||||
RequiresAdmin bool `gorm:"default:false" json:"requires_admin"`
|
||||
// AllowEditor indicates that editors are allowed to access the corresponding admin page
|
||||
// when this item represents an admin navigation entry (RequiresAdmin=true).
|
||||
AllowEditor bool `gorm:"default:false" json:"allow_editor"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the NavigationItem model
|
||||
@@ -44,7 +47,7 @@ func (n *NavigationItem) GetURL() string {
|
||||
if n.URL != "" {
|
||||
return n.URL
|
||||
}
|
||||
|
||||
|
||||
// Map page types to URLs for frontend
|
||||
if n.Type == NavTypePage && n.PageType != "" {
|
||||
pageURLMap := map[string]string{
|
||||
@@ -66,47 +69,47 @@ func (n *NavigationItem) GetURL() string {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Map admin page types to URLs
|
||||
if (n.Type == NavTypeInternal || n.Type == NavTypePage) && n.PageType != "" && n.RequiresAdmin {
|
||||
adminURLMap := map[string]string{
|
||||
"dashboard": "/admin",
|
||||
"analytics": "/admin/analytika",
|
||||
"teams": "/admin/tymy",
|
||||
"matches": "/admin/zapasy",
|
||||
"activities": "/admin/aktivity",
|
||||
"players": "/admin/hraci",
|
||||
"articles": "/admin/clanky",
|
||||
"categories": "/admin/kategorie",
|
||||
"about": "/admin/o-klubu",
|
||||
"videos": "/admin/videa",
|
||||
"gallery": "/admin/galerie",
|
||||
"scoreboard": "/admin/scoreboard",
|
||||
"scoreboard_remote": "/admin/scoreboard/remote",
|
||||
"clothing": "/admin/obleceni",
|
||||
"sponsors": "/admin/sponzori",
|
||||
"banners": "/admin/bannery",
|
||||
"messages": "/admin/zpravy",
|
||||
"contacts": "/admin/kontakty",
|
||||
"newsletter": "/admin/newsletter",
|
||||
"polls": "/admin/ankety",
|
||||
"comments": "/admin/komentare",
|
||||
"sweepstakes": "/admin/sweepstakes",
|
||||
"navigation": "/admin/navigace",
|
||||
"dashboard": "/admin",
|
||||
"analytics": "/admin/analytika",
|
||||
"teams": "/admin/tymy",
|
||||
"matches": "/admin/zapasy",
|
||||
"activities": "/admin/aktivity",
|
||||
"players": "/admin/hraci",
|
||||
"articles": "/admin/clanky",
|
||||
"categories": "/admin/kategorie",
|
||||
"about": "/admin/o-klubu",
|
||||
"videos": "/admin/videa",
|
||||
"gallery": "/admin/galerie",
|
||||
"scoreboard": "/admin/scoreboard",
|
||||
"scoreboard_remote": "/admin/scoreboard/remote",
|
||||
"clothing": "/admin/obleceni",
|
||||
"sponsors": "/admin/sponzori",
|
||||
"banners": "/admin/bannery",
|
||||
"messages": "/admin/zpravy",
|
||||
"contacts": "/admin/kontakty",
|
||||
"newsletter": "/admin/newsletter",
|
||||
"polls": "/admin/ankety",
|
||||
"comments": "/admin/komentare",
|
||||
"sweepstakes": "/admin/sweepstakes",
|
||||
"navigation": "/admin/navigace",
|
||||
"competition_aliases": "/admin/aliasy-soutezi",
|
||||
"prefetch": "/admin/prefetch",
|
||||
"users": "/admin/uzivatele",
|
||||
"settings": "/admin/nastaveni",
|
||||
"shortlinks": "/admin/shortlinks",
|
||||
"files": "/admin/soubory",
|
||||
"docs": "/admin/docs",
|
||||
"engagement": "/admin/engagement",
|
||||
"prefetch": "/admin/prefetch",
|
||||
"users": "/admin/uzivatele",
|
||||
"settings": "/admin/nastaveni",
|
||||
"shortlinks": "/admin/shortlinks",
|
||||
"files": "/admin/soubory",
|
||||
"docs": "/admin/docs",
|
||||
"engagement": "/admin/engagement",
|
||||
}
|
||||
if url, ok := adminURLMap[n.PageType]; ok {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return "#"
|
||||
}
|
||||
|
||||
@@ -130,7 +133,7 @@ func (s *SocialLink) GetIconName() string {
|
||||
if s.Icon != "" {
|
||||
return s.Icon
|
||||
}
|
||||
|
||||
|
||||
iconMap := map[string]string{
|
||||
"facebook": "FaFacebook",
|
||||
"instagram": "FaInstagram",
|
||||
@@ -141,10 +144,10 @@ func (s *SocialLink) GetIconName() string {
|
||||
"discord": "FaDiscord",
|
||||
"twitch": "FaTwitch",
|
||||
}
|
||||
|
||||
|
||||
if icon, ok := iconMap[s.Platform]; ok {
|
||||
return icon
|
||||
}
|
||||
|
||||
|
||||
return "FaLink"
|
||||
}
|
||||
|
||||
@@ -189,6 +189,9 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
editor.GET("/variants/:element_name", editorPreviewController.GetAvailableVariants)
|
||||
}
|
||||
|
||||
// Editor-allowed admin navigation (authenticated editors)
|
||||
protected.GET("/admin/navigation/editor", middleware.RoleAuth("editor"), navigationController.GetEditorAllowedAdminNav)
|
||||
|
||||
// Newsletter preferences token for current user
|
||||
protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser)
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
|
||||
// NewsletterAutomation handles all automated newsletter sending
|
||||
type NewsletterAutomation struct {
|
||||
db *gorm.DB
|
||||
emailSvc email.EmailService
|
||||
cacheDir string
|
||||
lastWeekly time.Time
|
||||
db *gorm.DB
|
||||
emailSvc email.EmailService
|
||||
cacheDir string
|
||||
lastWeekly time.Time
|
||||
lastMatchCheck time.Time
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ func NewNewsletterAutomation(db *gorm.DB, emailSvc email.EmailService) *Newslett
|
||||
// Start begins the newsletter automation loop
|
||||
func (na *NewsletterAutomation) Start() {
|
||||
log.Printf("[newsletter-automation] Starting automated newsletter service")
|
||||
|
||||
|
||||
// Run initial check after 1 minute
|
||||
time.AfterFunc(1*time.Minute, func() {
|
||||
na.RunCycle()
|
||||
})
|
||||
|
||||
|
||||
// Then run every 15 minutes
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
go func() {
|
||||
@@ -59,18 +59,18 @@ func (na *NewsletterAutomation) RunCycle() {
|
||||
log.Printf("[newsletter-automation] Skipped: disabled in settings")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
log.Printf("[newsletter-automation] Running cycle...")
|
||||
|
||||
|
||||
// Check for weekly digest
|
||||
na.checkWeeklyDigest()
|
||||
|
||||
|
||||
// Check for upcoming matches (reminders)
|
||||
na.checkUpcomingMatches()
|
||||
|
||||
|
||||
// Check for finished matches (results)
|
||||
na.checkFinishedMatches()
|
||||
|
||||
|
||||
log.Printf("[newsletter-automation] Cycle complete")
|
||||
}
|
||||
|
||||
@@ -79,40 +79,40 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
|
||||
if !na.isEnabled() {
|
||||
return fmt.Errorf("newsletter automation is disabled")
|
||||
}
|
||||
|
||||
|
||||
// Check if already sent
|
||||
var existing models.BlogNotification
|
||||
if err := na.db.Where("article_id = ?", article.ID).First(&existing).Error; err == nil {
|
||||
log.Printf("[newsletter-automation] Blog notification already sent for article %d", article.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Get subscribers interested in blogs
|
||||
subs := na.getSubscribersForType("blogs", article.CategoryName)
|
||||
if len(subs) == 0 {
|
||||
log.Printf("[newsletter-automation] No subscribers for blog notifications")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Build email content
|
||||
subject := fmt.Sprintf("Nový článek: %s", article.Title)
|
||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||
articleURL := fmt.Sprintf("%s/news/%s", baseFE, article.Slug)
|
||||
|
||||
|
||||
html := na.buildBlogNotificationHTML(article, articleURL)
|
||||
|
||||
|
||||
// Send to each subscriber
|
||||
recipients := make([]string, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
recipients = append(recipients, sub.Email)
|
||||
}
|
||||
|
||||
|
||||
err := na.sendNewsletterToRecipients(recipients, subject, html, "blog_release")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send blog notification: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// Record notification
|
||||
notif := models.BlogNotification{
|
||||
ArticleID: article.ID,
|
||||
@@ -121,7 +121,7 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(¬if)
|
||||
|
||||
|
||||
log.Printf("[newsletter-automation] Blog notification sent for article %d to %d recipients", article.ID, len(recipients))
|
||||
return nil
|
||||
}
|
||||
@@ -129,11 +129,11 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
|
||||
func (na *NewsletterAutomation) checkWeeklyDigest() {
|
||||
var settings models.Settings
|
||||
na.db.First(&settings)
|
||||
|
||||
|
||||
if !settings.EnableWeekly {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Get configured day and hour
|
||||
targetDay := strings.ToLower(strings.TrimSpace(settings.NewsletterWeeklyDay))
|
||||
if targetDay == "" {
|
||||
@@ -143,47 +143,47 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
|
||||
if targetHour < 0 || targetHour > 23 {
|
||||
targetHour = 9 // Default to 9 AM
|
||||
}
|
||||
|
||||
|
||||
now := time.Now()
|
||||
currentDay := strings.ToLower(now.Weekday().String()[:3])
|
||||
currentHour := now.Hour()
|
||||
|
||||
|
||||
// Check if it's the right day and hour
|
||||
if currentDay != targetDay || currentHour != targetHour {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if already sent today
|
||||
if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Get all subscribers interested in weekly digest
|
||||
subs := na.getSubscribersForType("weekly", "")
|
||||
if len(subs) == 0 {
|
||||
log.Printf("[newsletter-automation] No subscribers for weekly digest")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
log.Printf("[newsletter-automation] Sending weekly digest to %d subscribers", len(subs))
|
||||
|
||||
|
||||
// Build weekly content for each subscriber based on their preferences
|
||||
for _, sub := range subs {
|
||||
prefs := na.parsePreferences(sub)
|
||||
subject, html := BuildNewsletterDigest(na.cacheDir, prefs)
|
||||
|
||||
|
||||
if strings.TrimSpace(html) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
err := na.sendNewsletterToRecipients([]string{sub.Email}, subject, html, "weekly")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send weekly digest to %s: %v", sub.Email, err)
|
||||
}
|
||||
|
||||
|
||||
time.Sleep(200 * time.Millisecond) // Rate limiting
|
||||
}
|
||||
|
||||
|
||||
na.lastWeekly = now
|
||||
log.Printf("[newsletter-automation] Weekly digest sent")
|
||||
}
|
||||
@@ -191,35 +191,57 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
|
||||
func (na *NewsletterAutomation) checkUpcomingMatches() {
|
||||
var settings models.Settings
|
||||
na.db.First(&settings)
|
||||
|
||||
if !settings.EnableMatchReminders {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Determine effective enabling: use settings, or auto-activate if there are subscribers and a match is within 2 hours
|
||||
enabled := settings.EnableMatchReminders
|
||||
leadHours := settings.NewsletterReminderLeadHours
|
||||
if leadHours <= 0 {
|
||||
leadHours = 48 // Default 2 days
|
||||
}
|
||||
|
||||
|
||||
// Load match data from cache
|
||||
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
|
||||
matches := facrAllMatches(facr)
|
||||
|
||||
|
||||
now := time.Now()
|
||||
|
||||
|
||||
if !enabled {
|
||||
subs := na.getSubscribersForType("matches", "")
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
auto := false
|
||||
for _, match := range matches {
|
||||
matchTime := parseDateTimeISO(match.Date, match.Time)
|
||||
if matchTime.IsZero() || matchTime.Before(now) {
|
||||
continue
|
||||
}
|
||||
if matchTime.Sub(now).Hours() <= 2 {
|
||||
auto = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !auto {
|
||||
return
|
||||
}
|
||||
// Auto mode: restrict reminder window to 2 hours before kickoff
|
||||
leadHours = 2
|
||||
enabled = true
|
||||
}
|
||||
|
||||
for _, match := range matches {
|
||||
matchTime := parseDateTimeISO(match.Date, match.Time)
|
||||
if matchTime.IsZero() || matchTime.Before(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
hoursUntil := matchTime.Sub(now).Hours()
|
||||
|
||||
// Check for 48h reminder
|
||||
|
||||
// Check for lead-hour reminder (48h normally, 2h in auto mode)
|
||||
if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) {
|
||||
na.sendMatchReminder(match, "reminder_48h", leadHours)
|
||||
}
|
||||
|
||||
|
||||
// Check for day-of reminder (match starts in 0-6 hours)
|
||||
if hoursUntil <= 6 && hoursUntil > 0 {
|
||||
na.sendMatchReminder(match, "reminder_day", 0)
|
||||
@@ -234,13 +256,13 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
|
||||
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Get subscribers interested in matches and this competition
|
||||
subs := na.getSubscribersForType("matches", match.Competition)
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Build email content
|
||||
var subject string
|
||||
if notifType == "reminder_48h" {
|
||||
@@ -248,20 +270,20 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
|
||||
} else {
|
||||
subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away)
|
||||
}
|
||||
|
||||
|
||||
html := na.buildMatchReminderHTML(match, notifType)
|
||||
|
||||
|
||||
recipients := make([]string, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
recipients = append(recipients, sub.Email)
|
||||
}
|
||||
|
||||
|
||||
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send match reminder: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Record notification
|
||||
notif := models.MatchNotification{
|
||||
MatchID: matchKey,
|
||||
@@ -271,62 +293,91 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(¬if)
|
||||
|
||||
|
||||
log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients))
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) checkFinishedMatches() {
|
||||
var settings models.Settings
|
||||
na.db.First(&settings)
|
||||
|
||||
if !settings.EnableResults {
|
||||
return
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
currentHour := time.Now().Hour()
|
||||
quietStart := settings.NewsletterQuietStart
|
||||
quietEnd := settings.NewsletterQuietEnd
|
||||
|
||||
if quietStart > 0 && quietEnd > 0 {
|
||||
if quietStart < quietEnd {
|
||||
// e.g., 22:00 - 08:00
|
||||
if currentHour >= quietStart || currentHour < quietEnd {
|
||||
log.Printf("[newsletter-automation] In quiet hours, skipping result notifications")
|
||||
return
|
||||
|
||||
// Determine effective enabling. If disabled, auto-activate when there are subscribers and a recent result exists.
|
||||
enabled := settings.EnableResults
|
||||
|
||||
// Load match data
|
||||
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
|
||||
matches := facrAllMatches(facr)
|
||||
|
||||
now := time.Now()
|
||||
lookback := 6 * time.Hour // Check matches finished in last 6 hours
|
||||
|
||||
bypassQuiet := false
|
||||
if !enabled {
|
||||
subs := na.getSubscribersForType("scores", "")
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
auto := false
|
||||
for _, match := range matches {
|
||||
if match.Score == "" || !strings.Contains(match.Score, ":") {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// e.g., 08:00 - 22:00 (inverted, send only during these hours)
|
||||
if currentHour < quietStart && currentHour >= quietEnd {
|
||||
log.Printf("[newsletter-automation] Outside active hours, skipping result notifications")
|
||||
matchTime := parseDateTimeISO(match.Date, match.Time)
|
||||
if matchTime.IsZero() || matchTime.After(now) {
|
||||
continue
|
||||
}
|
||||
if now.Sub(matchTime) <= lookback {
|
||||
auto = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !auto {
|
||||
return
|
||||
}
|
||||
// Auto mode: send immediately when we have a result, ignoring quiet hours
|
||||
bypassQuiet = true
|
||||
enabled = true
|
||||
}
|
||||
|
||||
// Respect quiet hours only when explicitly enabled in settings (not in auto mode)
|
||||
if !bypassQuiet {
|
||||
currentHour := time.Now().Hour()
|
||||
quietStart := settings.NewsletterQuietStart
|
||||
quietEnd := settings.NewsletterQuietEnd
|
||||
|
||||
// Consider quiet hours configured when both bounds are within 0..23 and not equal
|
||||
if quietStart >= 0 && quietStart <= 23 && quietEnd >= 0 && quietEnd <= 23 && quietStart != quietEnd {
|
||||
inQuiet := false
|
||||
if quietStart < quietEnd {
|
||||
// Same-day interval, e.g., 08:00–22:00 => quiet when between start and end
|
||||
inQuiet = currentHour >= quietStart && currentHour < quietEnd
|
||||
} else {
|
||||
// Cross-midnight interval, e.g., 22:00–08:00 => quiet when hour >= start OR hour < end
|
||||
inQuiet = currentHour >= quietStart || currentHour < quietEnd
|
||||
}
|
||||
if inQuiet {
|
||||
log.Printf("[newsletter-automation] In quiet hours (%02d:00-%02d:00), skipping result notifications", quietStart, quietEnd)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load match data
|
||||
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
|
||||
matches := facrAllMatches(facr)
|
||||
|
||||
now := time.Now()
|
||||
lookback := 6 * time.Hour // Check matches finished in last 6 hours
|
||||
|
||||
|
||||
for _, match := range matches {
|
||||
if match.Score == "" || !strings.Contains(match.Score, ":") {
|
||||
continue // No score yet
|
||||
}
|
||||
|
||||
|
||||
matchTime := parseDateTimeISO(match.Date, match.Time)
|
||||
if matchTime.IsZero() || matchTime.After(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Check if match finished recently
|
||||
timeSinceMatch := now.Sub(matchTime)
|
||||
if timeSinceMatch > lookback {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
na.sendMatchResult(match)
|
||||
}
|
||||
}
|
||||
@@ -338,27 +389,27 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) {
|
||||
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Get subscribers interested in results
|
||||
subs := na.getSubscribersForType("scores", match.Competition)
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away)
|
||||
html := na.buildMatchResultHTML(match)
|
||||
|
||||
|
||||
recipients := make([]string, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
recipients = append(recipients, sub.Email)
|
||||
}
|
||||
|
||||
|
||||
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result")
|
||||
if err != nil {
|
||||
logger.Error("[newsletter-automation] Failed to send match result: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Record notification
|
||||
notif := models.MatchNotification{
|
||||
MatchID: matchKey,
|
||||
@@ -368,7 +419,7 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) {
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(¬if)
|
||||
|
||||
|
||||
log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients))
|
||||
}
|
||||
|
||||
@@ -384,7 +435,7 @@ func (na *NewsletterAutomation) isEnabled() bool {
|
||||
func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription {
|
||||
var subs []models.NewsletterSubscription
|
||||
na.db.Where("is_active = ?", true).Find(&subs)
|
||||
|
||||
|
||||
filtered := make([]models.NewsletterSubscription, 0)
|
||||
for _, sub := range subs {
|
||||
// Check if subscriber wants this content type
|
||||
@@ -409,7 +460,7 @@ func (na *NewsletterAutomation) getSubscribersForType(contentType, category stri
|
||||
filtered = append(filtered, sub)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -420,7 +471,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
|
||||
|
||||
// Parse content types
|
||||
if v, ok := sub.Preferences["blogs"].(bool); ok && v {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
|
||||
@@ -434,7 +485,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
|
||||
if v, ok := sub.Preferences["scores"].(bool); ok && v {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
|
||||
}
|
||||
|
||||
|
||||
// Parse categories/competitions
|
||||
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
|
||||
for _, c := range strings.Split(cats, ",") {
|
||||
@@ -443,7 +494,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return prefs
|
||||
}
|
||||
|
||||
@@ -453,12 +504,12 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
|
||||
Content: htmlContent,
|
||||
Recipients: recipients,
|
||||
}
|
||||
|
||||
|
||||
err := na.emailSvc.SendNewsletter(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// Log sent newsletter
|
||||
contentIDsJSON, _ := json.Marshal([]string{})
|
||||
logEntry := models.NewsletterSentLog{
|
||||
@@ -470,42 +521,44 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
na.db.Create(&logEntry)
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
|
||||
// Short description: prefer excerpt; otherwise derive from content
|
||||
desc := strings.TrimSpace(article.Excerpt)
|
||||
if desc == "" {
|
||||
plain := utils.SanitizeString(article.Content)
|
||||
if len(plain) > 260 {
|
||||
cut := 240
|
||||
if cut < len(plain) {
|
||||
for cut < len(plain) && plain[cut] != ' ' {
|
||||
cut++
|
||||
}
|
||||
}
|
||||
if cut > len(plain) { cut = len(plain) }
|
||||
plain = strings.TrimSpace(plain[:cut]) + "…"
|
||||
}
|
||||
desc = plain
|
||||
}
|
||||
// Short description: prefer excerpt; otherwise derive from content
|
||||
desc := strings.TrimSpace(article.Excerpt)
|
||||
if desc == "" {
|
||||
plain := utils.SanitizeString(article.Content)
|
||||
if len(plain) > 260 {
|
||||
cut := 240
|
||||
if cut < len(plain) {
|
||||
for cut < len(plain) && plain[cut] != ' ' {
|
||||
cut++
|
||||
}
|
||||
}
|
||||
if cut > len(plain) {
|
||||
cut = len(plain)
|
||||
}
|
||||
plain = strings.TrimSpace(plain[:cut]) + "…"
|
||||
}
|
||||
desc = plain
|
||||
}
|
||||
|
||||
// Category badge (if available)
|
||||
cat := strings.TrimSpace(article.CategoryName)
|
||||
var catHTML string
|
||||
if cat != "" {
|
||||
catHTML = fmt.Sprintf(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat))
|
||||
}
|
||||
// Category badge (if available)
|
||||
cat := strings.TrimSpace(article.CategoryName)
|
||||
var catHTML string
|
||||
if cat != "" {
|
||||
catHTML = fmt.Sprintf(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat))
|
||||
}
|
||||
|
||||
// Cover image (optional)
|
||||
var imgHTML string
|
||||
if strings.TrimSpace(article.ImageURL) != "" {
|
||||
imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL))
|
||||
}
|
||||
// Cover image (optional)
|
||||
var imgHTML string
|
||||
if strings.TrimSpace(article.ImageURL) != "" {
|
||||
imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL))
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
html := fmt.Sprintf(`
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 12px;">Nový článek na webu</h2>
|
||||
<div style="border-left: 4px solid #2563eb; padding: 18px; background: #f8fafc; margin: 16px 0; border-radius:6px;">
|
||||
@@ -518,18 +571,18 @@ func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Articl
|
||||
</div>
|
||||
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL)
|
||||
|
||||
return html
|
||||
return html
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
|
||||
var intro string
|
||||
if notifType == "reminder_48h" {
|
||||
intro = "Připomínáme nadcházející zápas:"
|
||||
} else {
|
||||
intro = "Zápas je dnes!"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
var intro string
|
||||
if notifType == "reminder_48h" {
|
||||
intro = "Připomínáme nadcházející zápas:"
|
||||
} else {
|
||||
intro = "Zápas je dnes!"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
|
||||
|
||||
@@ -541,7 +594,7 @@ func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType st
|
||||
</div>
|
||||
</div>
|
||||
`, intro, htmlEsc(match.Home), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Time), htmlEsc(match.Competition))
|
||||
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
@@ -557,6 +610,6 @@ func (na *NewsletterAutomation) buildMatchResultHTML(match Match) string {
|
||||
</div>
|
||||
</div>
|
||||
`, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition))
|
||||
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ func fetchZonerama(link string) error {
|
||||
}
|
||||
// Profile fetch - gets album metadata only (no photos)
|
||||
albumLimit := envInt("ZONERAMA_ALBUM_LIMIT", 10) // Fetch up to 10 albums metadata
|
||||
apiBase := "https://zonerama.tdvorak.dev/zonerama?link=" + url.QueryEscape(strings.TrimSpace(link)) + "&album_limit=" + strconv.Itoa(albumLimit) + "&photo_limit=0"
|
||||
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
|
||||
apiBase := fmt.Sprintf("%s/zonerama?link=%s&album_limit=%d&photo_limit=0", base, url.QueryEscape(strings.TrimSpace(link)), albumLimit)
|
||||
log.Printf("[prefetch] Fetching Zonerama profile: %s (album_limit=%d, no photos)", apiBase, albumLimit)
|
||||
|
||||
// Increase timeout to 60s since the API can take longer to fetch
|
||||
@@ -223,8 +224,9 @@ func fetchZoneramaAlbums(albums []struct {
|
||||
}
|
||||
|
||||
// Fetch album with photos
|
||||
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
|
||||
url.QueryEscape(album.URL), photoLimit)
|
||||
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
|
||||
apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
|
||||
base, url.QueryEscape(album.URL), photoLimit)
|
||||
|
||||
log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"fotbal-club/pkg/email"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SweepstakesService encapsulates business logic for sweepstakes
|
||||
@@ -83,18 +83,30 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
}
|
||||
// Determine number of winners
|
||||
nWinners := 0
|
||||
for _, p := range prizes { nWinners += max(0, p.Quantity) }
|
||||
for _, p := range prizes {
|
||||
nWinners += max(0, p.Quantity)
|
||||
}
|
||||
if nWinners == 0 {
|
||||
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes }
|
||||
if cur.TotalPrizes > 0 {
|
||||
nWinners = cur.TotalPrizes
|
||||
}
|
||||
}
|
||||
// Cap winners to a safe maximum
|
||||
if nWinners > 100 { nWinners = 100 }
|
||||
if nWinners > len(entries) { nWinners = len(entries) }
|
||||
if nWinners > 100 {
|
||||
nWinners = 100
|
||||
}
|
||||
if nWinners > len(entries) {
|
||||
nWinners = len(entries)
|
||||
}
|
||||
|
||||
// Build seed
|
||||
effSeed := strings.TrimSpace(seed)
|
||||
if effSeed == "" { effSeed = strings.TrimSpace(cur.DrawSeed) }
|
||||
if effSeed == "" { effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano()) }
|
||||
if effSeed == "" {
|
||||
effSeed = strings.TrimSpace(cur.DrawSeed)
|
||||
}
|
||||
if effSeed == "" {
|
||||
effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano())
|
||||
}
|
||||
// Deterministic RNG from SHA-256
|
||||
h := sha256.Sum256([]byte(effSeed))
|
||||
base := binary.LittleEndian.Uint64(h[:8])
|
||||
@@ -125,18 +137,27 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
for j := 0; j < q && pos < len(idx); j++ {
|
||||
cand := entries[idx[pos]]
|
||||
pos++
|
||||
if picked[cand.UserID] { j--; continue }
|
||||
if picked[cand.UserID] {
|
||||
j--
|
||||
continue
|
||||
}
|
||||
picked[cand.UserID] = true
|
||||
assign(cand.UserID, cand.ID, &prizes[i])
|
||||
if len(winners) >= nWinners { break }
|
||||
if len(winners) >= nWinners {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(winners) >= nWinners {
|
||||
break
|
||||
}
|
||||
if len(winners) >= nWinners { break }
|
||||
}
|
||||
// If still need more (when TotalPrizes used)
|
||||
for len(winners) < nWinners && pos < len(idx) {
|
||||
cand := entries[idx[pos]]
|
||||
pos++
|
||||
if picked[cand.UserID] { continue }
|
||||
if picked[cand.UserID] {
|
||||
continue
|
||||
}
|
||||
picked[cand.UserID] = true
|
||||
assign(cand.UserID, cand.ID, nil)
|
||||
}
|
||||
@@ -151,9 +172,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
vis := cur.EndAt.Add(72 * time.Hour)
|
||||
if err := tx.Model(&models.Sweepstake{}).Where("id = ?", cur.ID).Updates(map[string]interface{}{
|
||||
"winners_selected_at": now,
|
||||
"visibility_until": vis,
|
||||
"draw_seed": effSeed,
|
||||
"status": "finalized",
|
||||
"visibility_until": vis,
|
||||
"draw_seed": effSeed,
|
||||
"status": "finalized",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -163,15 +184,21 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
for _, w := range winners {
|
||||
var user models.User
|
||||
_ = tx.First(&user, w.UserID).Error
|
||||
if strings.TrimSpace(user.Email) == "" { continue }
|
||||
if strings.TrimSpace(user.Email) == "" {
|
||||
continue
|
||||
}
|
||||
// Localize end date to Czech format in Europe/Prague timezone
|
||||
loc, _ := time.LoadLocation("Europe/Prague")
|
||||
endsLocal := cur.EndAt.In(loc)
|
||||
endsAtCz := endsLocal.Format("02. 01. 2006 15:04")
|
||||
_ = s.Email.SendEmail(&email.EmailData{
|
||||
Subject: "Vyhráli jste v soutěži!",
|
||||
To: []string{strings.TrimSpace(user.Email)},
|
||||
Template: "sweepstake_winner_user",
|
||||
Data: map[string]interface{}{
|
||||
"Title": cur.Title,
|
||||
"Title": cur.Title,
|
||||
"PrizeName": w.PrizeName,
|
||||
"EndsAt": cur.EndAt.Format(time.RFC1123),
|
||||
"EndsAt": endsAtCz,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -179,14 +206,16 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
var set models.Settings
|
||||
_ = tx.First(&set).Error
|
||||
adminTo := strings.TrimSpace(set.ContactEmail)
|
||||
if adminTo == "" { adminTo = strings.TrimSpace(set.SMTPFrom) }
|
||||
if adminTo == "" {
|
||||
adminTo = strings.TrimSpace(set.SMTPFrom)
|
||||
}
|
||||
if adminTo != "" {
|
||||
_ = s.Email.SendEmail(&email.EmailData{
|
||||
Subject: "Soutěž – vybraní výherci",
|
||||
To: []string{adminTo},
|
||||
Template: "sweepstake_winner_admin",
|
||||
Data: map[string]interface{}{
|
||||
"Title": cur.Title,
|
||||
"Title": cur.Title,
|
||||
"WinnersCount": len(winners),
|
||||
},
|
||||
})
|
||||
@@ -196,4 +225,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
|
||||
})
|
||||
}
|
||||
|
||||
func max(a, b int) int { if a > b { return a } ; return b }
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user