dev day #90 🥳

This commit is contained in:
Tomas Dvorak
2025-11-12 20:31:37 +01:00
parent 8762bde4bf
commit f3db65d350
103 changed files with 4053 additions and 2189 deletions
+360 -196
View File
@@ -72,7 +72,65 @@ func generateTeamNameAliases(name string) []string {
es := abbreviateAmpersand(e)
if es != "" && es != base && es != t && es != e { add(es) }
variants := []string{t, s, e, es}
// Generate PN-abbreviated variants like "... n. X." / "... p. X." from full forms (nad/pod)
makePNAbbrevs := func(s string) []string {
if strings.TrimSpace(s) == "" { return nil }
// Build variants for "nad <Word>" / "pod <Word>" ->
// n. W., n.W., n. W, n.W (and p. analogs)
mk := func(in string, re *regexp.Regexp, repPrefix string, withFinalDot bool, withSpace bool) string {
return re.ReplaceAllStringFunc(in, func(m string) string {
sub := re.FindStringSubmatch(m)
if len(sub) < 2 { return m }
letter := firstRuneUpper(sub[1])
if letter == "" { return m }
if withFinalDot {
if withSpace { return repPrefix + " " + letter + "." }
return repPrefix + letter + "."
}
if withSpace { return repPrefix + " " + letter }
return repPrefix + letter
})
}
// spaced + with dot
a := mk(s, rePNNadWord, "n.", true, true)
a = mk(a, rePNPodWord, "p.", true, true)
// no space + with dot
b := mk(s, rePNNadWord, "n.", true, false)
b = mk(b, rePNPodWord, "p.", true, false)
// spaced + without final dot
c := mk(s, rePNNadWord, "n.", false, true)
c = mk(c, rePNPodWord, "p.", false, true)
// no space + without final dot
d := mk(s, rePNNadWord, "n.", false, false)
d = mk(d, rePNPodWord, "p.", false, false)
// collect distinct, non-empty, changed variants
seen := map[string]struct{}{}
out := []string{}
addv := func(x string) {
x = strings.TrimSpace(x)
if x == "" || x == s { return }
if _, ok := seen[x]; ok { return }
seen[x] = struct{}{}
out = append(out, x)
}
addv(a); addv(b); addv(c); addv(d)
return out
}
for _, v := range []string{t, e} {
for _, p := range makePNAbbrevs(v) { add(p) }
}
// Also generate and add versions with common club prefixes stripped (SK, FK, MFK, TJ, 1.BFK, ...)
st := stripOrgPrefixes(t)
se := stripOrgPrefixes(e)
if st != "" && st != t { add(st) }
if se != "" && se != e { add(se) }
// PN abbreviations for stripped versions as well
for _, v := range []string{st, se} {
for _, p := range makePNAbbrevs(v) { add(p) }
}
variants := []string{t, s, e, es, st, se}
for _, v := range variants {
if strings.TrimSpace(v) == "" { continue }
nd := strings.ReplaceAll(v, ".", "")
@@ -84,7 +142,7 @@ func generateTeamNameAliases(name string) []string {
return out
}
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$`)
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)[\s]*$`)
func trimLegalSuffixes(s string) string {
return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, ""))
@@ -96,6 +154,25 @@ var (
reMultiSpace = regexp.MustCompile(`\s+`)
)
var (
rePNNadWord = regexp.MustCompile(`(?i)\bnad\s+([\p{L}-]+)`)
rePNPodWord = regexp.MustCompile(`(?i)\bpod\s+([\p{L}-]+)`)
)
// Remove leading organization tokens like "1.BFK", "FK", "SK", "TJ", "MFK", "SFC", ...
var reLeadingOrg = regexp.MustCompile(`(?i)^(?:\d+\.)?\s*(?:sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\.?\s+`)
func stripOrgPrefixes(s string) string {
x := strings.TrimSpace(s)
if x == "" { return x }
for {
nx := reLeadingOrg.ReplaceAllString(x, "")
nx = strings.TrimSpace(nx)
if nx == x || nx == "" { return nx }
x = nx
}
}
func expandPNAbbrev(s string) string {
if s == "" { return s }
x := reAbbrevP.ReplaceAllString(s, "pod ")
@@ -667,14 +744,15 @@ func (bc *BaseController) GetStandings(c *gin.Context) {
if err := bc.DB.Find(&tlovs).Error; err == nil {
tloByID := map[string]models.TeamLogoOverride{}
for _, it := range tlovs {
tloByID[it.ExternalTeamID] = it
if it.ExternalTeamID == "" { continue }
tloByID[strings.ToLower(it.ExternalTeamID)] = it
}
for i := range rows {
id, _ := rows[i]["team_id"].(string)
if id == "" {
continue
}
if tlo, ok := tloByID[id]; ok {
if tlo, ok := tloByID[strings.ToLower(id)]; ok {
if strings.TrimSpace(tlo.TeamName) != "" {
rows[i]["team"] = tlo.TeamName
}
@@ -2059,101 +2137,166 @@ func computeEstimatedReadMinutes(html string) int {
return minutes
}
// GetAdminMatches returns cached matches merged with DB overrides (admin only)
// GetAdminMatches returns cached FACR matches merged with DB overrides (admin only)
func (bc *BaseController) GetAdminMatches(c *gin.Context) {
// Read cached events
p := filepath.Join("cache", "prefetch", "events_upcoming.json")
f, err := os.Open(p)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
return
}
defer f.Close()
var matches []map[string]interface{}
if err := json.NewDecoder(f).Decode(&matches); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"})
return
}
// Read cached FACR club info (contains competitions with matches)
p := filepath.Join("cache", "prefetch", "facr_club_info.json")
f, err := os.Open(p)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached FACR matches"})
return
}
defer f.Close()
// Load overrides
var movs []models.MatchOverride
if err := bc.DB.Find(&movs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
return
}
movByID := map[string]models.MatchOverride{}
for _, m := range movs {
movByID[m.ExternalMatchID] = m
}
var facr struct {
Competitions []struct {
ID string `json:"id"`
Name string `json:"name"`
Matches []struct {
MatchID string `json:"match_id"`
DateTime string `json:"date_time"`
Date string `json:"date"`
Time string `json:"time"`
Home string `json:"home"`
HomeTeam string `json:"home_team"`
HomeID string `json:"home_id"`
HomeTeamID string `json:"home_team_id"`
HomeTeamFACRID string `json:"home_team_facr_id"`
Away string `json:"away"`
AwayTeam string `json:"away_team"`
AwayID string `json:"away_id"`
AwayTeamID string `json:"away_team_id"`
AwayTeamFACRID string `json:"away_team_facr_id"`
Score string `json:"score"`
Venue string `json:"venue"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
} `json:"matches"`
} `json:"competitions"`
}
if err := json.NewDecoder(f).Decode(&facr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist FACR cache"})
return
}
var tlovs []models.TeamLogoOverride
if err := bc.DB.Find(&tlovs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
return
}
tloByTeam := map[string]models.TeamLogoOverride{}
for _, t := range tlovs {
tloByTeam[t.ExternalTeamID] = t
}
// Helper to pick first non-empty string
firstNonEmpty := func(ss ...string) string {
for _, s := range ss {
s = strings.TrimSpace(s)
if s != "" {
return s
}
}
return ""
}
// Apply overrides in-place
for _, m := range matches {
// External match ID
var matchID string
if v, ok := m["match_id"].(string); ok {
matchID = v
} else if v2, ok2 := m["id"].(string); ok2 {
matchID = v2
}
// Flatten and normalize to a simple slice of maps
items := make([]map[string]any, 0, 256)
for _, comp := range facr.Competitions {
for _, m := range comp.Matches {
id := strings.TrimSpace(m.MatchID)
if id == "" {
continue
}
home := firstNonEmpty(m.Home, m.HomeTeam)
away := firstNonEmpty(m.Away, m.AwayTeam)
homeID := firstNonEmpty(m.HomeID, m.HomeTeamID, m.HomeTeamFACRID)
awayID := firstNonEmpty(m.AwayID, m.AwayTeamID, m.AwayTeamFACRID)
item := map[string]any{
"id": id,
"match_id": id,
"date_time": strings.TrimSpace(m.DateTime),
"date": strings.TrimSpace(m.Date),
"time": strings.TrimSpace(m.Time),
"competitionName": strings.TrimSpace(comp.Name),
"competition_id": strings.TrimSpace(comp.ID),
"home": home,
"home_team": home,
"home_id": homeID,
"away": away,
"away_team": away,
"away_id": awayID,
"score": strings.TrimSpace(m.Score),
"venue": strings.TrimSpace(m.Venue),
"home_logo_url": strings.TrimSpace(m.HomeLogoURL),
"away_logo_url": strings.TrimSpace(m.AwayLogoURL),
}
items = append(items, item)
}
}
if ov, ok := movByID[matchID]; ok {
if ov.HomeNameOverride != nil {
m["home"] = *ov.HomeNameOverride
m["home_team"] = *ov.HomeNameOverride
}
if ov.AwayNameOverride != nil {
m["away"] = *ov.AwayNameOverride
m["away_team"] = *ov.AwayNameOverride
}
if ov.VenueOverride != nil {
m["venue"] = *ov.VenueOverride
}
if ov.DateTimeOverride != nil {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
if ov.AwayLogoURL != nil {
m["away_logo_url"] = *ov.AwayLogoURL
}
}
// Load overrides and apply
var movs []models.MatchOverride
if err := bc.DB.Find(&movs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
return
}
movByID := map[string]models.MatchOverride{}
for _, m := range movs {
movByID[m.ExternalMatchID] = m
}
// Team-logo overrides by team id
if homeID, ok := m["home_id"].(string); ok {
if tlo, ok := tloByTeam[homeID]; ok {
if tlo.LogoURL != "" {
m["home_logo_url"] = tlo.LogoURL
}
if tlo.TeamName != "" {
m["home"] = tlo.TeamName
}
}
}
if awayID, ok := m["away_id"].(string); ok {
if tlo, ok := tloByTeam[awayID]; ok {
if tlo.LogoURL != "" {
m["away_logo_url"] = tlo.LogoURL
}
if tlo.TeamName != "" {
m["away"] = tlo.TeamName
}
}
}
}
var tlovs []models.TeamLogoOverride
if err := bc.DB.Find(&tlovs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
return
}
tloByTeam := map[string]models.TeamLogoOverride{}
for _, t := range tlovs {
tloByTeam[t.ExternalTeamID] = t
}
c.JSON(http.StatusOK, matches)
for _, m := range items {
matchID, _ := m["match_id"].(string)
if ov, ok := movByID[matchID]; ok {
if ov.HomeNameOverride != nil {
m["home"] = *ov.HomeNameOverride
m["home_team"] = *ov.HomeNameOverride
}
if ov.AwayNameOverride != nil {
m["away"] = *ov.AwayNameOverride
m["away_team"] = *ov.AwayNameOverride
}
if ov.VenueOverride != nil {
m["venue"] = *ov.VenueOverride
}
if ov.DateTimeOverride != nil {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
m["time"] = ov.DateTimeOverride.Format("15:04")
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
if ov.AwayLogoURL != nil {
m["away_logo_url"] = *ov.AwayLogoURL
}
}
if homeID, _ := m["home_id"].(string); homeID != "" {
if tlo, ok := tloByTeam[homeID]; ok {
if strings.TrimSpace(tlo.LogoURL) != "" {
m["home_logo_url"] = tlo.LogoURL
}
if strings.TrimSpace(tlo.TeamName) != "" {
m["home"] = tlo.TeamName
m["home_team"] = tlo.TeamName
}
}
}
if awayID, _ := m["away_id"].(string); awayID != "" {
if tlo, ok := tloByTeam[awayID]; ok {
if strings.TrimSpace(tlo.LogoURL) != "" {
m["away_logo_url"] = tlo.LogoURL
}
if strings.TrimSpace(tlo.TeamName) != "" {
m["away"] = tlo.TeamName
m["away_team"] = tlo.TeamName
}
}
}
}
c.JSON(http.StatusOK, items)
}
// --- Admin: Match & Team Logo Overrides ---
@@ -2411,6 +2554,8 @@ func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
return
}
// Best-effort: update public snapshot cache so frontend fallback sees latest aliases
go bc.writeTeamLogoOverridesCache()
c.JSON(http.StatusOK, item)
}
@@ -4841,115 +4986,134 @@ func (bc *BaseController) DeleteCategory(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
return
}
// Successful deletion
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}
// UploadImage handles generic file uploads (images, documents, archives)
func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
// Enforce maximum upload size (bytes)
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
return
}
name := strings.TrimSpace(f.Filename)
ext := strings.ToLower(filepath.Ext(name))
allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".pdf": true}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
return
}
// Light content sniffing to ensure the uploaded payload matches the declared extension
// and basic SVG sanitization (reject obvious script/event patterns).
if src, err := f.Open(); err == nil {
buf := make([]byte, 2048)
n, _ := src.Read(buf)
_ = src.Close()
detected := http.DetectContentType(buf[:n])
validCT := false
switch ext {
case ".pdf":
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
case ".svg":
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
default:
validCT = strings.HasPrefix(detected, "image/")
}
if !validCT {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
return
}
}
// Additional SVG content check against common script vectors
if ext == ".svg" {
if src2, err := f.Open(); err == nil {
defer src2.Close()
check := make([]byte, 65536)
n, _ := io.ReadFull(src2, check)
lower := strings.ToLower(string(check[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
}
}
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
}
_ = os.MkdirAll(dir, 0o755)
b := make([]byte, 8)
_, _ = rand.Read(b)
randHex := hex.EncodeToString(b)
outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext)
outPath := filepath.Join(dir, outName)
if err := c.SaveUploadedFile(f, outPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
urlPath := "/uploads/" + outName
ft := services.NewFileTracker(bc.DB)
mimeType := f.Header.Get("Content-Type")
_ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil)
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
// Enforce maximum upload size (bytes)
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
return
}
name := strings.TrimSpace(f.Filename)
ext := strings.ToLower(filepath.Ext(name))
// Allow images, PDFs, Office docs, text, archives, and common media
allowed := map[string]bool{
// Images
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true,
// Documents
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".csv": true,
// Archives
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true,
// Media
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true,
}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
return
}
// Light content sniffing to ensure payload matches extension and sanitize SVGs
if src, err := f.Open(); err == nil {
defer src.Close()
buf := make([]byte, 2048)
n, _ := io.ReadFull(src, buf)
if n < 0 { n = 0 }
dl := strings.ToLower(http.DetectContentType(buf[:n]))
// Build absolute URL from request (supports proxies)
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
// Take the first value if comma-separated
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" {
host = h
}
}
}
// Append forwarded port when host has no explicit port and it's non-default
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
absolute := scheme + "://" + host + urlPath
c.JSON(http.StatusOK, gin.H{
// Always return a backend-relative path for storage
"url": urlPath,
// Convenience absolute URL for immediate usage in UIs
"absolute_url": absolute,
// Basic metadata (best-effort)
"name": outName,
"type": mimeType,
"size": f.Size,
})
validCT := false
switch ext {
case ".pdf":
validCT = strings.Contains(dl, "pdf") || dl == "application/octet-stream"
case ".svg":
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
lower := strings.ToLower(string(buf[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
case ".docx", ".xlsx", ".pptx":
validCT = strings.Contains(dl, "officedocument") || strings.Contains(dl, "application/zip") || dl == "application/octet-stream"
case ".doc", ".xls", ".ppt":
validCT = strings.Contains(dl, "msword") || strings.Contains(dl, "vnd.ms-") || dl == "application/octet-stream"
case ".zip":
validCT = strings.Contains(dl, "zip") || dl == "application/octet-stream"
case ".rar":
validCT = strings.Contains(dl, "rar") || dl == "application/octet-stream"
case ".7z":
validCT = strings.Contains(dl, "7z") || dl == "application/octet-stream"
case ".tar":
validCT = strings.Contains(dl, "tar") || dl == "application/octet-stream"
case ".gz":
validCT = strings.Contains(dl, "gzip") || dl == "application/octet-stream"
case ".txt", ".csv":
validCT = strings.HasPrefix(dl, "text/") || dl == "application/octet-stream"
case ".mp4", ".avi", ".mov":
validCT = strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
case ".mp3", ".wav":
validCT = strings.HasPrefix(dl, "audio/") || dl == "application/octet-stream"
default:
validCT = strings.HasPrefix(dl, "image/")
}
if !validCT {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
return
}
}
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
}
_ = os.MkdirAll(dir, 0o755)
b := make([]byte, 8)
_, _ = rand.Read(b)
randHex := hex.EncodeToString(b)
outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext)
outPath := filepath.Join(dir, outName)
if err := c.SaveUploadedFile(f, outPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
urlPath := "/uploads/" + outName
ft := services.NewFileTracker(bc.DB)
mimeType := f.Header.Get("Content-Type")
_ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil)
// Build absolute URL from request (supports proxies)
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" { host = h }
}
}
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
absolute := scheme + "://" + host + urlPath
c.JSON(http.StatusOK, gin.H{
"url": urlPath,
"absolute_url": absolute,
"name": outName,
"type": mimeType,
"size": f.Size,
})
}
// Global newsletter automation instance (set from main)
+174 -8
View File
@@ -5,9 +5,12 @@ import (
"strings"
"time"
"encoding/json"
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -24,7 +27,54 @@ func (cc *CommentController) AdminListBans(c *gin.Context) {
if err := cc.DB.Where("until IS NULL OR until > ?", now).Order("created_at DESC").Find(&bans).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load bans"}); return
}
c.JSON(http.StatusOK, gin.H{"items": bans})
// Load users
uids := make([]uint, 0, len(bans))
seen := map[uint]bool{}
for _, b := range bans { if !seen[b.UserID] { uids = append(uids, b.UserID); seen[b.UserID] = true } }
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
users := map[uint]userRow{}
if len(uids) > 0 {
var rows []userRow
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
for _, r := range rows { users[r.ID] = r }
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
var profs []prof
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
}
type banOut struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Reason string `json:"reason"`
Until *time.Time `json:"until"`
CreatedAt time.Time `json:"created_at"`
CreatedByID uint `json:"created_by_id"`
User struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
Username string `json:"username,omitempty"`
} `json:"user"`
}
out := make([]banOut, 0, len(bans))
for _, b := range bans {
o := banOut{ ID: b.ID, UserID: b.UserID, Reason: b.Reason, Until: b.Until, CreatedAt: b.CreatedAt, CreatedByID: b.CreatedByID }
if u, ok := users[b.UserID]; ok {
o.User.ID = u.ID
o.User.FirstName = u.FirstName
o.User.LastName = u.LastName
o.User.Email = u.Email
o.User.Role = u.Role
}
if v, ok := usernameByID[b.UserID]; ok { o.User.Username = v }
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// Admin: lift a ban early by setting until = now
@@ -74,11 +124,12 @@ func (cc *CommentController) React(c *gin.Context) {
return
}
uid, _ := c.Get("userID")
// delete previous reaction for this user
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
// create new
// Upsert reaction to ensure exactly one reaction per (comment_id,user_id)
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
if err := cc.DB.Create(&r).Error; err != nil {
if err := cc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{"type": r.Type, "updated_at": time.Now()}),
}).Create(&r).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
@@ -140,8 +191,71 @@ func (cc *CommentController) AdminList(c *gin.Context) {
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
}
// Prepare target labels (titles) for admin visibility: articles and events
articleIDs := make([]uint, 0)
eventIDs := make([]uint, 0)
for _, it := range items {
switch it.TargetType {
case "article":
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
articleIDs = append(articleIDs, uint(v))
}
case "event":
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
eventIDs = append(eventIDs, uint(v))
}
}
}
articleTitleByID := map[uint]string{}
if len(articleIDs) > 0 {
type row struct{ ID uint; Title string }
var rows []row
_ = cc.DB.Table("articles").Select("id, title").Where("id IN ?", articleIDs).Scan(&rows).Error
for _, r := range rows { articleTitleByID[r.ID] = r.Title }
}
eventTitleByID := map[uint]string{}
if len(eventIDs) > 0 {
type row struct{ ID uint; Title string }
var rows []row
_ = cc.DB.Table("events").Select("id, title").Where("id IN ?", eventIDs).Scan(&rows).Error
for _, r := range rows { eventTitleByID[r.ID] = r.Title }
}
out := make([]commentOutput, 0, len(items))
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; if adminLiked[r.ID] { co.AdminLiked = true }; out = append(out, co) }
for _, r := range items {
co := toOutput(r)
if v, ok := repCounts[r.ID]; ok { co.Reports = v }
if adminLiked[r.ID] { co.AdminLiked = true }
// Compose human label for target
switch r.TargetType {
case "article":
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
if t, ok := articleTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
co.TargetLabel = fmt.Sprintf("Článek: %s (#%s)", t, r.TargetID)
} else {
co.TargetLabel = fmt.Sprintf("Článek #%s", r.TargetID)
}
} else {
co.TargetLabel = "Článek"
}
case "event":
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
if t, ok := eventTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
co.TargetLabel = fmt.Sprintf("Aktivita: %s (#%s)", t, r.TargetID)
} else {
co.TargetLabel = fmt.Sprintf("Aktivita #%s", r.TargetID)
}
} else {
co.TargetLabel = "Aktivita"
}
case "gallery_album":
co.TargetLabel = fmt.Sprintf("Galerie album #%s", r.TargetID)
case "youtube_video":
co.TargetLabel = fmt.Sprintf("YouTube video %s", r.TargetID)
default:
co.TargetLabel = fmt.Sprintf("%s #%s", r.TargetType, r.TargetID)
}
out = append(out, co)
}
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
@@ -181,9 +295,60 @@ func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
// Admin: list unban requests
func (cc *CommentController) AdminListUnban(c *gin.Context) {
// Only pending requests
var items []models.UnbanRequest
_ = cc.DB.Order("created_at DESC").Find(&items).Error
c.JSON(http.StatusOK, gin.H{"items": items})
_ = cc.DB.Where("status = ?", "pending").Order("created_at DESC").Find(&items).Error
// Load users and usernames
uids := make([]uint, 0, len(items))
seen := map[uint]bool{}
for _, it := range items { if !seen[it.UserID] { uids = append(uids, it.UserID); seen[it.UserID] = true } }
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
users := map[uint]userRow{}
if len(uids) > 0 {
var rows []userRow
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
for _, r := range rows { users[r.ID] = r }
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
var profs []prof
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
}
type unbanOut struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Message string `json:"message"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ResolvedByID *uint `json:"resolved_by_id,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
User struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
Username string `json:"username,omitempty"`
} `json:"user"`
}
out := make([]unbanOut, 0, len(items))
for _, it := range items {
var u userRow
if r, ok := users[it.UserID]; ok { u = r }
o := unbanOut{
ID: it.ID, UserID: it.UserID, Message: it.Message, Status: it.Status, CreatedAt: it.CreatedAt, ResolvedByID: it.ResolvedByID, ResolvedAt: it.ResolvedAt,
}
o.User.ID = u.ID
o.User.FirstName = u.FirstName
o.User.LastName = u.LastName
o.User.Email = u.Email
o.User.Role = u.Role
if v, ok := usernameByID[it.UserID]; ok { o.User.Username = v }
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// Admin: resolve unban request
@@ -218,6 +383,7 @@ type commentOutput struct {
ID uint `json:"id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetLabel string `json:"target_label,omitempty"`
ParentID *uint `json:"parent_id,omitempty"`
Content string `json:"content"`
Status string `json:"status"`
+141 -10
View File
@@ -33,16 +33,93 @@ func (cc *ContactController) GetContactMessages(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Pagination
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
if page <= 0 { page = 1 }
if limit <= 0 || limit > 200 { limit = 50 }
items, total, err := models.GetContactMessages(cc.DB, page, limit)
if err != nil {
if page <= 0 {
page = 1
}
if limit <= 0 || limit > 200 {
limit = 50
}
// Filters
search := strings.TrimSpace(c.DefaultQuery("search", ""))
isReadParam := strings.TrimSpace(c.DefaultQuery("isRead", ""))
var isRead *bool
if isReadParam != "" {
v := strings.ToLower(isReadParam)
if v == "true" || v == "1" {
t := true
isRead = &t
} else if v == "false" || v == "0" {
f := false
isRead = &f
}
}
// Sorting (map UI fields to DB columns)
sortBy := strings.TrimSpace(c.DefaultQuery("sortBy", "createdAt"))
sortOrder := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sortOrder", "desc")))
if sortOrder != "asc" {
sortOrder = "desc"
}
sortField := "created_at"
switch sortBy {
case "name":
sortField = "name"
case "email":
sortField = "email"
case "subject":
sortField = "subject"
case "createdAt", "created_at":
sortField = "created_at"
}
// Build query
q := cc.DB.Model(&models.ContactMessage{})
if search != "" {
s := "%" + strings.ToLower(search) + "%"
q = q.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(subject) LIKE ? OR LOWER(message) LIKE ?", s, s, s, s)
}
if isRead != nil {
q = q.Where("is_read = ?", *isRead)
}
// Count total
var total int64
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": limit})
// Fetch page
var items []models.ContactMessage
offset := (page - 1) * limit
if err := q.Order(sortField+" "+sortOrder).Offset(offset).Limit(limit).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
// Compute pages (ceil)
pages := 1
if limit > 0 {
pages = (int(total) + limit - 1) / limit
if pages == 0 {
pages = 1
}
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"pagination": gin.H{
"total": total,
"page": page,
"limit": limit,
"pages": pages,
},
})
}
// MarkMessageAsRead marks a message as read (admin)
@@ -75,19 +152,36 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active).Error
enabled := active > 0
// Persist flag
// Persist flag and, when enabling for the first time, ensure weekly digest is active with sane defaults
var s models.Settings
_ = cc.DB.First(&s).Error
if s.ID == 0 {
s = models.Settings{}
}
changed := false
if s.NewsletterEnabled != enabled {
s.NewsletterEnabled = enabled
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
} else {
_ = cc.DB.Save(&s).Error
changed = true
}
if enabled {
// Auto-activate weekly digest and preset schedule if not configured
if !s.EnableWeekly {
s.EnableWeekly = true
changed = true
}
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
s.NewsletterWeeklyDay = "sun"
changed = true
}
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
s.NewsletterWeeklyHour = 9
changed = true
}
}
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
} else if changed {
_ = cc.DB.Save(&s).Error
}
// Update runtime
@@ -736,6 +830,8 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
var total, active int64
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
var s models.Settings
_ = cc.DB.First(&s).Error
var subs []models.NewsletterSubscription
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
sample := make([]string, 0, len(subs))
@@ -751,6 +847,31 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
}
}
next := time.Now().Add(interval)
// Compute next scheduled weekly time (exact), using settings (default Sun 09:00)
weeklyDay := strings.ToLower(strings.TrimSpace(s.NewsletterWeeklyDay))
if weeklyDay == "" { weeklyDay = "sun" }
weeklyHour := s.NewsletterWeeklyHour
if weeklyHour < 0 || weeklyHour > 23 { weeklyHour = 9 }
// find next occurrence
now := time.Now()
target := time.Date(now.Year(), now.Month(), now.Day(), weeklyHour, 0, 0, 0, now.Location())
toWD := func(d string) time.Weekday {
switch d {
case "mon": return time.Monday
case "tue": return time.Tuesday
case "wed": return time.Wednesday
case "thu": return time.Thursday
case "fri": return time.Friday
case "sat": return time.Saturday
default: return time.Sunday
}
}
for i := 0; i < 8; i++ {
if target.Weekday() == toWD(weeklyDay) && target.After(now) {
break
}
target = target.Add(24 * time.Hour)
}
c.JSON(http.StatusOK, gin.H{
"total_subscribers": total,
"active_subscribers": active,
@@ -758,6 +879,16 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
"interval_minutes": int(interval.Minutes()),
"next_approximate": next,
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
// Scheduling detail
"weekly_enabled": s.EnableWeekly,
"weekly_day": weeklyDay,
"weekly_hour": weeklyHour,
"weekly_next_scheduled": target,
"matches_enabled": s.EnableMatchReminders,
"reminder_lead_hours": s.NewsletterReminderLeadHours,
"results_enabled": s.EnableResults,
"quiet_start": s.NewsletterQuietStart,
"quiet_end": s.NewsletterQuietEnd,
})
}
+69 -1
View File
@@ -21,6 +21,29 @@ type EngagementController struct {
Email email.EmailService
}
// parseMetaTime tries to parse time from metadata value which can be string (RFC3339 or YYYY-MM-DD) or numeric unix seconds.
func parseMetaTime(v interface{}) time.Time {
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
if s == "" { return time.Time{} }
if ts, err := time.Parse(time.RFC3339, s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02T15:04", s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02", s); err == nil { return ts }
case float64:
// JSON numbers decode to float64
if t <= 0 { return time.Time{} }
return time.Unix(int64(t), 0)
case int64:
if t <= 0 { return time.Time{} }
return time.Unix(t, 0)
case int:
if t <= 0 { return time.Time{} }
return time.Unix(int64(t), 0)
}
return time.Time{}
}
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
return &EngagementController{DB: db, Email: es}
}
@@ -224,7 +247,31 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
c.JSON(http.StatusOK, items)
// Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to.
now := time.Now()
filtered := make([]models.RewardItem, 0, len(items))
for _, it := range items {
// Mandatory unlock reward is always available
if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") {
filtered = append(filtered, it)
continue
}
var startPtr, endPtr *time.Time
if it.Metadata != nil {
if v, ok := it.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
}
if v, ok := it.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
} else if v2, ok2 := it.Metadata["expires_at"]; ok2 { // alias
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
}
}
if startPtr != nil && now.Before(*startPtr) { continue }
if endPtr != nil && now.After(*endPtr) { continue }
filtered = append(filtered, it)
}
c.JSON(http.StatusOK, filtered)
}
// POST /api/v1/engagement/redeem (auth)
@@ -252,6 +299,27 @@ func (ec *EngagementController) Redeem(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not active"})
return
}
// Check validity window (metadata.valid_from/valid_to or expires_at)
if item.Metadata != nil {
var startPtr, endPtr *time.Time
if v, ok := item.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
}
if v, ok := item.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
} else if v2, ok2 := item.Metadata["expires_at"]; ok2 {
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
}
now := time.Now()
if startPtr != nil && now.Before(*startPtr) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not currently available"})
return
}
if endPtr != nil && now.After(*endPtr) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward validity has ended"})
return
}
}
if item.Stock == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"})
return
+2 -2
View File
@@ -228,7 +228,7 @@ func (ec *ErrorController) AdminListExternal(c *gin.Context) {
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
@@ -263,7 +263,7 @@ func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
+1 -1
View File
@@ -620,7 +620,7 @@ func (fc *FilesController) RefreshFileTracking(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": "File tracking refreshed successfully",
"message": "Sledování souborů bylo úspěšně aktualizováno",
"stats": stats,
})
}
+93 -22
View File
@@ -162,35 +162,106 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
return
}
var updates models.NavigationItem
if err := c.ShouldBindJSON(&updates); err != nil {
// Bind into a generic map to know which fields are present (partial update)
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields
item.Label = updates.Label
item.URL = updates.URL
item.Icon = updates.Icon
item.Type = updates.Type
item.PageType = updates.PageType
item.PageID = updates.PageID
item.Visible = updates.Visible
item.DisplayOrder = updates.DisplayOrder
item.ParentID = updates.ParentID
item.Target = updates.Target
item.CSSClass = updates.CSSClass
item.RequiresAuth = updates.RequiresAuth
item.RequiresAdmin = updates.RequiresAdmin
if err := nc.DB.Save(&item).Error; err != nil {
// Allow-list of updatable fields and basic type normalization
updates := map[string]interface{}{}
if v, ok := raw["label"]; ok {
if s, ok2 := v.(string); ok2 { updates["label"] = s }
}
if v, ok := raw["url"]; ok {
if s, ok2 := v.(string); ok2 { updates["url"] = s }
}
if v, ok := raw["icon"]; ok {
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
}
if v, ok := raw["type"]; ok {
if s, ok2 := v.(string); ok2 { updates["type"] = s }
}
if v, ok := raw["page_type"]; ok {
if s, ok2 := v.(string); ok2 { updates["page_type"] = s }
}
if v, ok := raw["page_id"]; ok {
switch t := v.(type) {
case float64:
updates["page_id"] = int(t)
case int:
updates["page_id"] = t
case int32:
updates["page_id"] = int(t)
case int64:
updates["page_id"] = int(t)
case nil:
updates["page_id"] = nil
}
}
if v, ok := raw["visible"]; ok {
if b, ok2 := v.(bool); ok2 { updates["visible"] = b }
}
if v, ok := raw["display_order"]; ok {
switch t := v.(type) {
case float64:
updates["display_order"] = int(t)
case int:
updates["display_order"] = t
case int32:
updates["display_order"] = int(t)
case int64:
updates["display_order"] = int(t)
}
}
if v, ok := raw["parent_id"]; ok {
switch t := v.(type) {
case float64:
updates["parent_id"] = int(t)
case int:
updates["parent_id"] = t
case int32:
updates["parent_id"] = int(t)
case int64:
updates["parent_id"] = int(t)
case nil:
updates["parent_id"] = nil
}
}
if v, ok := raw["target"]; ok {
if s, ok2 := v.(string); ok2 { updates["target"] = s }
}
if v, ok := raw["css_class"]; ok {
if s, ok2 := v.(string); ok2 { updates["css_class"] = s }
}
if v, ok := raw["requires_auth"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_auth"] = b }
}
if v, ok := raw["requires_admin"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_admin"] = b }
}
if len(updates) == 0 {
// Nothing to update
c.JSON(http.StatusOK, item)
return
}
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update navigation item",
"details": err.Error(),
})
return
}
// Reload to return consistent, fresh data
if err := nc.DB.First(&item, id).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Updated", "id": id})
return
}
c.JSON(http.StatusOK, item)
}
@@ -524,8 +595,8 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
if err := createChild(obsah, "Kategorie", "categories", 2); err != nil { return err }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
// Kategorie admin page removed (categories derived from competition aliases)
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
media, err := createCategory("Média")
if err != nil { return err }