mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #69
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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í 0–99"})
|
||||
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í 50–250 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í 30–200 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í 0–99"})
|
||||
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í 50–250 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í 30–200 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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user