This commit is contained in:
Tomas Dvorak
2025-10-23 22:26:50 +02:00
parent 63700eedb2
commit 70ea0c3c91
75 changed files with 3337 additions and 1160 deletions
+104
View File
@@ -20,6 +20,96 @@ type AIController struct {
DB *gorm.DB
}
// GenerateCSS creates scoped CSS for a page element
func (ac *AIController) GenerateCSS(c *gin.Context) {
var req aiCSSRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
rootSelector := strings.TrimSpace(req.RootSelector)
if rootSelector == "" {
en := strings.TrimSpace(req.ElementName)
if en == "" { en = "element" }
rootSelector = fmt.Sprintf("[data-element=\"%s\"]", en)
}
themeJSON, _ := json.Marshal(req.Theme)
stylesJSON, _ := json.Marshal(req.CurrentStyles)
system := "Jsi zkušený CSS návrhář pro klubové weby. Piš čistý, přístupný a responzivní CSS. VÝSTUP POUZE JSON: {\"css\":\"...\"}. Nepoužívej reset, neovlivňuj globální prvky. CSS MUSÍ být scope-nuté POUZE pod kořenový selektor, žádný selektor mimo. Používej CSS proměnné (např. --club-primary, --club-secondary). Čeština není nutná v kódu, ale požadavky jsou v češtině."
user := fmt.Sprintf("Požadavek: %s\nKořenový selektor: %s\nAktuální CSS (může být prázdné):\n---\n%s\n---\nAktuální styly (JSON): %s\nTéma (JSON): %s\nBreakpoints: %v\nPožadavky: 1) Scope pouze pod kořenový selektor. 2) Žádné !important. 3) Media queries pro mobil/tablet/desktop dle potřeby. 4) Zaměř se na vzhled prvků uvnitř bloku. 5) Nepřidávej inline styly ani globální sel. 6) Používej proměnné, zachovej kontrast a čitelnost.",
strings.TrimSpace(req.Prompt), rootSelector, strings.TrimSpace(req.CurrentCSS), string(stylesJSON), string(themeJSON), req.Breakpoints)
callModel := func(modelName string) (string, int, error) {
payload := map[string]interface{}{
"model": modelName,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.3,
"max_tokens": 1200,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil { return "", http.StatusInternalServerError, err }
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
if err != nil { return "", http.StatusBadGateway, err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err }
if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") }
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
content, _, err := callModel(model)
if err != nil || strings.TrimSpace(content) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return }
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
}
}
sanitized := sanitizeAIResponse(content)
var out aiCSSResponse
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
if strings.TrimSpace(out.CSS) == "" {
out.CSS = fmt.Sprintf("%s { }", rootSelector)
}
c.JSON(http.StatusOK, out)
}
// GenerateAboutPage creates about page content using the OpenRouter API
func (ac *AIController) GenerateAboutPage(c *gin.Context) {
var req aiAboutRequest
@@ -194,6 +284,20 @@ type aiAboutResponse struct {
SEODescription string `json:"seo_description"`
}
type aiCSSRequest struct {
Prompt string `json:"prompt" binding:"required"`
ElementName string `json:"element_name"`
RootSelector string `json:"root_selector"`
CurrentCSS string `json:"current_css"`
CurrentStyles map[string]interface{} `json:"current_styles"`
Theme map[string]string `json:"theme"`
Breakpoints []int `json:"breakpoints"`
}
type aiCSSResponse struct {
CSS string `json:"css"`
}
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
func (ac *AIController) GenerateBlog(c *gin.Context) {
var req aiBlogRequest
+5 -1
View File
@@ -27,7 +27,7 @@ func NewArticleController(db *gorm.DB) *ArticleController {
// CreateArticleRequest represents the request body for creating an article
type CreateArticleRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Content string `json:"content"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
@@ -138,6 +138,10 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
if req.Published != nil {
published = *req.Published
}
if published && strings.TrimSpace(req.Content) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Obsah je povinný pro publikovaný článek"})
return
}
var publishedAt *time.Time
if req.PublishedAt != nil && strings.TrimSpace(*req.PublishedAt) != "" {
if t, err := time.Parse(time.RFC3339, *req.PublishedAt); err == nil {
+37 -4
View File
@@ -109,7 +109,7 @@ func (ac *AuthController) Register(c *gin.Context) {
// Check if this is the first user (admin)
var userCount int64
ac.DB.Model(&models.User{}).Count(&userCount)
role := "editor"
role := "fan"
isFirstUser := userCount == 0
if isFirstUser {
role = "admin"
@@ -287,6 +287,39 @@ func (ac *AuthController) GetCurrentUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(user.(*models.User))})
}
// UpdateCurrentUser allows the authenticated user to update their personal information
func (ac *AuthController) UpdateCurrentUser(c *gin.Context) {
u, exists := c.Get("user")
if !exists || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
current := u.(*models.User)
var req struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
fn := strings.TrimSpace(req.FirstName)
ln := strings.TrimSpace(req.LastName)
if fn != "" {
current.FirstName = fn
}
if ln != "" {
current.LastName = ln
}
if err := ac.DB.Save(current).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(current)})
}
// AdminExists returns whether any admin user exists
func (ac *AuthController) AdminExists(c *gin.Context) {
var count int64
@@ -435,8 +468,8 @@ func (ac *AuthController) AdminCreateUser(c *gin.Context) {
return
}
// role
role := req.Role
if role != "admin" && role != "editor" {
role := strings.TrimSpace(req.Role)
if role != "admin" && role != "editor" && role != "fan" {
role = "editor"
}
// active
@@ -527,7 +560,7 @@ func (ac *AuthController) AdminUpdateUser(c *gin.Context) {
user.Email = email
}
if req.Role != "" {
if req.Role != "admin" && req.Role != "editor" {
if req.Role != "admin" && req.Role != "editor" && req.Role != "fan" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
return
}
+221 -21
View File
@@ -40,6 +40,31 @@ type BaseController struct {
DB *gorm.DB
}
func normalizePhone(raw, country string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
re := regexp.MustCompile(`[\s\-\.\(\)]`)
s = re.ReplaceAllString(s, "")
if strings.HasPrefix(s, "00") {
s = "+" + s[2:]
}
if strings.HasPrefix(s, "+") {
return s
}
if matched, _ := regexp.MatchString(`^420\d{9}$`, s); matched {
return "+" + s
}
if matched, _ := regexp.MatchString(`^\d{9}$`, s); matched {
c := strings.ToLower(country)
if strings.Contains(c, "česk") || strings.Contains(c, "czech") {
return "+420" + s
}
}
return s
}
// GetMatchesHistory returns cached past matches with overrides applied (public)
// Optional query: q= filters by home/away/venue/competition
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
@@ -600,6 +625,24 @@ func (bc *BaseController) CreateCategory(c *gin.Context) {
Name: name,
Description: strings.TrimSpace(body.Description),
}
// Ensure category slug is set and unique
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
@@ -1715,10 +1758,31 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
ClubName string `json:"club_name"`
ClubLogoURL string `json:"club_logo_url"`
ClubURL string `json:"club_url"`
// Social profiles (optional)
FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"`
YoutubeURL string `json:"youtube_url"`
// Gallery (optional)
GalleryURL string `json:"gallery_url"`
GalleryLabel string `json:"gallery_label"`
// Location/Contact (optional)
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"`
LocationLatitude float64 `json:"location_latitude"`
LocationLongitude float64 `json:"location_longitude"`
MapStyle string `json:"map_style"`
// Frontpage style (optional)
FrontpageStyle string `json:"frontpage_style"`
// Theme (optional, can set later)
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
AccentColor string `json:"accent_color"`
@@ -1726,7 +1790,9 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
TextColor string `json:"text_color"`
FontHeading string `json:"font_heading"`
FontBody string `json:"font_body"`
SMTP *struct {
// SMTP optional
SMTP *struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
@@ -1789,24 +1855,36 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
s.YoutubeURL = v
}
// Always allow updating SMTP via setup when admin already exists (idempotent)
// Gallery
if body.GalleryURL != "" {
s.GalleryURL = strings.TrimSpace(body.GalleryURL)
}
if body.GalleryLabel != "" {
s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
}
// Frontpage style
if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle
}
// SMTP overrides from initial setup
if body.SMTP != nil {
if host := strings.TrimSpace(body.SMTP.Host); host != "" {
s.SMTPHost = host
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
s.SMTPHost = v
}
if body.SMTP.Port > 0 {
s.SMTPPort = body.SMTP.Port
}
if u := strings.TrimSpace(body.SMTP.Username); u != "" {
s.SMTPUser = u
if v := strings.TrimSpace(body.SMTP.Username); v != "" {
s.SMTPUser = v
s.SMTPAuth = true
}
if p := body.SMTP.Password; p != "" {
s.SMTPPassword = p
if v := body.SMTP.Password; v != "" {
s.SMTPPassword = v
}
if from := strings.TrimSpace(body.SMTP.From); from != "" {
s.SMTPFrom = from
if v := strings.TrimSpace(body.SMTP.From); v != "" {
s.SMTPFrom = v
}
// Default FromName if empty
if s.SMTPFromName == "" {
s.SMTPFromName = "Fotbal Club"
}
@@ -1834,7 +1912,6 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
return
}
}
// Trigger background prefetch and YouTube cache refresh when settings are updated post-setup
scheme := "http"
if c.Request.TLS != nil {
@@ -2035,7 +2112,7 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
s.ContactCountry = v
}
if v := strings.TrimSpace(body.ContactPhone); v != "" {
s.ContactPhone = v
s.ContactPhone = normalizePhone(v, body.ContactCountry)
}
if v := strings.TrimSpace(body.ContactEmail); v != "" {
s.ContactEmail = v
@@ -2530,7 +2607,25 @@ func (bc *BaseController) CreateArticle(c *gin.Context) {
var cat models.Category
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create category with unique slug
cat = models.Category{Name: name}
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL (kategorie)"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
@@ -2685,6 +2780,8 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
YouTubeVideoTitle *string `json:"youtube_video_title"`
YouTubeVideoURL *string `json:"youtube_video_url"`
YouTubeVideoThumbnail *string `json:"youtube_video_thumbnail"`
// Attachments array from frontend, stored as JSON string in model
Attachments []map[string]any `json:"attachments"`
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
@@ -2729,7 +2826,25 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
var cat models.Category
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create category with unique slug
cat = models.Category{Name: name}
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL (kategorie)"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
@@ -2758,8 +2873,25 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
art.Featured = *body.Featured
}
if body.Slug != nil {
art.Slug = strings.TrimSpace(*body.Slug)
}
s := strings.TrimSpace(*body.Slug)
if s == "" {
s = makeSlug(art.Title)
}
// Ensure slug is unique across other articles
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Article{}).Where("slug = ? AND id != ?", s, art.ID).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
art.Slug = s
}
if body.SeoTitle != nil {
art.SEOTitle = strings.TrimSpace(*body.SeoTitle)
}
@@ -2791,6 +2923,12 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
if body.YouTubeVideoThumbnail != nil {
art.YouTubeVideoThumbnail = strings.TrimSpace(*body.YouTubeVideoThumbnail)
}
// Attachments
if len(body.Attachments) > 0 {
if b, err := json.Marshal(body.Attachments); err == nil {
art.Attachments = string(b)
}
}
// Auto-fill SEO if still empty after updates
if strings.TrimSpace(art.SEOTitle) == "" && strings.TrimSpace(art.Title) != "" {
@@ -2801,7 +2939,7 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
}
if err := bc.DB.Save(&art).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"})
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny", "error": err.Error()})
return
}
if art.ImageURL == "" {
@@ -3108,13 +3246,46 @@ func (bc *BaseController) CreatePlayer(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
return
}
// Auto-split full name if last name missing and first contains spaces; drop middle names
first := strings.TrimSpace(body.FirstName)
last := strings.TrimSpace(body.LastName)
if last == "" && strings.Contains(first, " ") {
parts := strings.Fields(first)
if len(parts) >= 2 {
first = parts[0]
last = parts[len(parts)-1]
}
}
if first == "" || last == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Jméno a příjmení jsou povinné"})
return
}
// Validate numeric limits
if body.JerseyNumber != nil {
if *body.JerseyNumber < 0 || *body.JerseyNumber > 99 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Číslo dresu musí být v rozmezí 099"})
return
}
}
if body.Height != nil {
if *body.Height < 50 || *body.Height > 250 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Výška musí být v rozmezí 50250 cm"})
return
}
}
if body.Weight != nil {
if *body.Weight < 30 || *body.Weight > 200 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Váha musí být v rozmezí 30200 kg"})
return
}
}
p := models.Player{
FirstName: strings.TrimSpace(body.FirstName),
LastName: strings.TrimSpace(body.LastName),
FirstName: first,
LastName: last,
Position: strings.TrimSpace(body.Position),
Nationality: strings.TrimSpace(body.Nationality),
Email: strings.TrimSpace(body.Email),
Phone: strings.TrimSpace(body.Phone),
Phone: normalizePhone(body.Phone, ""),
ImageURL: strings.TrimSpace(body.ImageURL),
}
if body.TeamID != nil {
@@ -3204,6 +3375,18 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
if body.LastName != nil {
p.LastName = strings.TrimSpace(*body.LastName)
}
// Auto-split if last name empty and first contains spaces; ensure both present
if strings.TrimSpace(p.LastName) == "" && strings.Contains(strings.TrimSpace(p.FirstName), " ") {
parts := strings.Fields(strings.TrimSpace(p.FirstName))
if len(parts) >= 2 {
p.FirstName = parts[0]
p.LastName = parts[len(parts)-1]
}
}
if strings.TrimSpace(p.FirstName) == "" || strings.TrimSpace(p.LastName) == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Jméno a příjmení jsou povinné"})
return
}
if body.Position != nil {
p.Position = strings.TrimSpace(*body.Position)
}
@@ -3220,15 +3403,27 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
p.TeamID = *body.TeamID
}
if body.JerseyNumber != nil {
if *body.JerseyNumber < 0 || *body.JerseyNumber > 99 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Číslo dresu musí být v rozmezí 099"})
return
}
p.JerseyNumber = *body.JerseyNumber
}
if body.Nationality != nil {
p.Nationality = strings.TrimSpace(*body.Nationality)
}
if body.Height != nil {
if *body.Height < 50 || *body.Height > 250 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Výška musí být v rozmezí 50250 cm"})
return
}
p.Height = *body.Height
}
if body.Weight != nil {
if *body.Weight < 30 || *body.Weight > 200 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Váha musí být v rozmezí 30200 kg"})
return
}
p.Weight = *body.Weight
}
if body.IsActive != nil {
@@ -3238,7 +3433,7 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
p.Email = strings.TrimSpace(*body.Email)
}
if body.Phone != nil {
p.Phone = strings.TrimSpace(*body.Phone)
p.Phone = normalizePhone(*body.Phone, "")
}
if body.ImageURL != nil {
p.ImageURL = strings.TrimSpace(*body.ImageURL)
@@ -4067,7 +4262,7 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
@@ -4336,7 +4531,12 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
s.ContactCountry = strings.TrimSpace(*body.ContactCountry)
}
if body.ContactPhone != nil {
s.ContactPhone = strings.TrimSpace(*body.ContactPhone)
v := strings.TrimSpace(*body.ContactPhone)
country := s.ContactCountry
if body.ContactCountry != nil {
country = strings.TrimSpace(*body.ContactCountry)
}
s.ContactPhone = normalizePhone(v, country)
}
if body.ContactEmail != nil {
s.ContactEmail = strings.TrimSpace(*body.ContactEmail)
+90 -10
View File
@@ -1,6 +1,7 @@
package controllers
import (
"crypto/rand"
"fmt"
"net/http"
"net/url"
@@ -26,6 +27,32 @@ type ContactController struct {
emailService email.EmailService
}
// GetNewsletterTokenForUser returns a short-lived newsletter preferences token for the authenticated user's email
// GET /api/v1/newsletter/token/me (auth required)
func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
u, ok := c.Get("user")
if !ok || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
user := u.(*models.User)
email := strings.TrimSpace(strings.ToLower(user.Email))
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "User email not available"})
return
}
// Generate a 24h token for managing newsletter preferences
token, err := utils.GenerateSubscriberToken(email, 60*24)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
// SendNewsletterDigest builds and sends a digest newsletter based on a template type (admin only)
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
@@ -921,18 +948,71 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
return
}
// Generate a subscriber token to include in follow-up emails (preferences links)
token, _ := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// Auto-create fan user account if not exists
var existingUser models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&existingUser).Error; err == gorm.ErrRecordNotFound {
// Generate a strong random password (16 chars, mixed set)
genPassword := func(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz"
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
const digits = "0123456789"
const symbols = "!@#$%^&*()-_=+[]{}?"
pool := letters + upper + digits + symbols
b := make([]byte, n)
for i := 0; i < n; i++ {
var rb [1]byte
if _, err := rand.Read(rb[:]); err != nil {
b[i] = pool[(time.Now().UnixNano()+int64(i))%int64(len(pool))]
continue
}
b[i] = pool[int(rb[0])%len(pool)]
}
return string(b)
}
plain := genPassword(16)
hashed, hErr := utils.HashPassword(plain)
if hErr == nil {
u := models.User{
Email: strings.TrimSpace(strings.ToLower(subscription.Email)),
Password: hashed,
FirstName: "",
LastName: "",
Role: "fan",
IsActive: true,
}
if err := cc.DB.Create(&u).Error; err != nil {
logger.Error("Failed to auto-create fan user for newsletter: %v", err)
} else {
// Send credentials email
data := map[string]interface{}{
"Email": subscription.Email,
"Password": plain,
"LoginURL": baseFE + "/login",
"ResetURL": baseFE + "/forgot-password",
"ManageURL": setupURL,
"UnsubscribeURL": unsubscribeURL,
}
credEmail := &email.EmailData{
Subject: "Váš fan účet byl vytvořen",
To: []string{subscription.Email},
Template: "fan_account_created",
Data: data,
}
if err := cc.emailService.SendEmail(credEmail); err != nil {
logger.Error("Failed to send fan account created email: %v", err)
}
}
}
}
// Send setup email (link with token) AND welcome introduction email in goroutines
go func() {
// Generate token and build setup + unsubscribe URLs
token, tErr := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
if tErr != nil {
logger.Error("Failed to generate subscriber token: %v", tErr)
return
}
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// 1) Setup email
setupEmail := &email.EmailData{
Subject: "Nastavte svůj newsletter",
+10 -2
View File
@@ -357,14 +357,14 @@ func (pc *PollController) DeletePoll(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Poll deleted successfully"})
}
// Vote handles vote submission
func (pc *PollController) Vote(c *gin.Context) {
id := c.Param("id")
var input struct {
OptionIDs []uint `json:"option_ids" binding:"required,min=1"`
SessionToken string `json:"session_token"`
VoterName string `json:"voter_name"`
VoterEmail string `json:"voter_email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
@@ -412,6 +412,12 @@ func (pc *PollController) Vote(c *gin.Context) {
return
}
// If not authenticated, don't persist personal info even if provided
if !hasUser {
input.VoterName = ""
input.VoterEmail = ""
}
// Check if already voted
ipHash := pc.hashIP(c.ClientIP())
sessionToken := input.SessionToken
@@ -462,6 +468,8 @@ func (pc *PollController) Vote(c *gin.Context) {
IPHash: ipHash,
UserAgent: userAgent,
SessionToken: sessionToken,
VoterName: input.VoterName,
VoterEmail: input.VoterEmail,
}
if hasUser {