Files
MyClub/internal/controllers/article_controller.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

337 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"encoding/json"
"fmt"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ArticleController handles article-related requests
type ArticleController struct {
DB *gorm.DB
}
// NewArticleController creates a new ArticleController
func NewArticleController(db *gorm.DB) *ArticleController {
return &ArticleController{DB: db}
}
// CreateArticleRequest represents the request body for creating an article
type CreateArticleRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
Published *bool `json:"published"`
PublishedAt *string `json:"published_at"`
Featured *bool `json:"featured"`
Slug string `json:"slug"`
SeoTitle string `json:"seo_title"`
SeoDescription string `json:"seo_description"`
OgImageURL string `json:"og_image_url"`
GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"`
GalleryPhotoIDs []string `json:"gallery_photo_ids"`
YouTubeVideoID string `json:"youtube_video_id"`
YouTubeVideoTitle string `json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
Attachments []AttachmentItem `json:"attachments"`
}
// AttachmentItem represents a single attachment entry for an article
type AttachmentItem struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size *int `json:"size,omitempty"`
}
// CreateArticle creates a new article with comprehensive error handling
func (ac *ArticleController) CreateArticle(c *gin.Context) {
// 1. Check authentication
uVal, ok := c.Get("user")
if !ok {
logger.Error("CreateArticle: User not authenticated")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Uživatel není přihlášen"})
return
}
user, ok := uVal.(*models.User)
if !ok || user == nil {
logger.Error("CreateArticle: Invalid user object in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Neplatný uživatel"})
return
}
logger.Info("CreateArticle: Request from user %d (%s)", user.ID, user.Email)
// 2. Parse and validate request body
var req CreateArticleRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("CreateArticle: Invalid request body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Neplatná data požadavku",
"details": err.Error(),
})
return
}
// Log the request
logger.Info("CreateArticle: Creating article '%s' by user %d", req.Title, user.ID)
// 3. Generate or validate slug
slug := strings.TrimSpace(req.Slug)
if slug == "" {
slug = makeSlug(req.Title)
logger.Info("CreateArticle: Generated slug '%s' from title", slug)
}
if slug == "" {
slug = fmt.Sprintf("article-%d", time.Now().Unix())
logger.Warn("CreateArticle: Using fallback slug '%s'", slug)
}
// 4. Ensure unique slug
originalSlug := slug
for i := 0; i < 50; i++ {
var count int64
if err := ac.DB.Model(&models.Article{}).Where("slug = ?", slug).Count(&count).Error; err != nil {
logger.Error("CreateArticle: Error checking slug uniqueness: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při kontrole jedinečnosti URL"})
return
}
if count == 0 {
break
}
slug = fmt.Sprintf("%s-%d", originalSlug, i+1)
logger.Info("CreateArticle: Slug collision, trying '%s'", slug)
}
// 5. Resolve or create category
var categoryID *uint
if req.CategoryID != nil && *req.CategoryID > 0 {
categoryID = req.CategoryID
logger.Info("CreateArticle: Using category ID %d", *categoryID)
} else if strings.TrimSpace(req.CategoryName) != "" {
categoryName := strings.TrimSpace(req.CategoryName)
var category models.Category
err := ac.DB.Where("name = ?", categoryName).First(&category).Error
if err == gorm.ErrRecordNotFound {
// Create new category with unique slug derived from name
s := makeSlug(categoryName)
if s == "" {
s = fmt.Sprintf("category-%d", time.Now().Unix())
}
orig := s
for i := 0; i < 50; i++ {
var sc int64
if err := ac.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
category = models.Category{Name: categoryName, Slug: s}
if err := ac.DB.Create(&category).Error; err != nil {
logger.Error("CreateArticle: Error creating category '%s': %v", categoryName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit kategorii"})
return
}
logger.Info("CreateArticle: Created new category '%s' with ID %d", categoryName, category.ID)
} else if err != nil {
logger.Error("CreateArticle: Error finding category: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při hledání kategorie"})
return
}
catID := category.ID
categoryID = &catID
}
// 6. Set published status and published_at
published := false
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 {
publishedAt = &t
} else {
logger.Warn("CreateArticle: Could not parse published_at: %v", err)
}
}
if published && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
// 7. Set featured status
featured := false
if req.Featured != nil {
featured = *req.Featured
}
// 8. Calculate estimated read time
readTime := computeEstimatedReadMinutes(req.Content)
logger.Info("CreateArticle: Estimated read time: %d minutes", readTime)
// 9. Prepare SEO fields with fallbacks
seoTitle := strings.TrimSpace(req.SeoTitle)
if seoTitle == "" {
seoTitle = strings.TrimSpace(req.Title)
}
seoDesc := strings.TrimSpace(req.SeoDescription)
if seoDesc == "" {
seoDesc = deriveSeoDescription(req.Content)
}
// 10. Set image: prefer provided, otherwise try Grok (XAI) main image, then fallback to static placeholder
imageURL := strings.TrimSpace(req.ImageURL)
if imageURL == "" && isXAIEnabled() {
promptParts := []string{
fmt.Sprintf("Titulní obrázek k článku na oficiálním webu fotbalového klubu. Titulek článku: \"%s\".", strings.TrimSpace(req.Title)),
}
if strings.TrimSpace(req.CategoryName) != "" {
promptParts = append(promptParts, fmt.Sprintf("Téma / soutěž: %s.", strings.TrimSpace(req.CategoryName)))
}
promptParts = append(promptParts,
"Zaměř se na atmosféru klubu stadion, hráče a fanoušky v klubových barvách.",
"Styl: realistický, moderní, sportovní, bez textu, široký banner v poměru 16:9 vhodný jako hlavní obrázek článku.",
)
prompt := strings.Join(promptParts, " ")
urls, _, err := callXAIImage(getXAIImageModel(), prompt, "1920x1080", 1)
if err != nil {
logger.Error("CreateArticle: XAI image generation failed: %v", err)
} else if len(urls) > 0 {
candidate := strings.TrimSpace(urls[0])
if candidate != "" {
imageURL = candidate
logger.Info("CreateArticle: Using XAI-generated main image")
}
}
}
if imageURL == "" {
imageURL = "/dist/img/logo-club-empty.svg"
logger.Info("CreateArticle: Using default image")
}
// 11. Create the article object
authorID := user.ID
article := models.Article{
Title: strings.TrimSpace(req.Title),
Content: req.Content,
AuthorID: &authorID,
CategoryID: categoryID,
Published: published,
PublishedAt: publishedAt,
ImageURL: imageURL,
ReadTime: readTime,
Slug: slug,
SEOTitle: seoTitle,
SEODescription: seoDesc,
Featured: featured,
}
// 12. Set optional OG image
if trimmed := strings.TrimSpace(req.OgImageURL); trimmed != "" {
article.OGImageURL = trimmed
}
// 13. Set gallery fields
if trimmed := strings.TrimSpace(req.GalleryAlbumID); trimmed != "" {
article.GalleryAlbumID = trimmed
}
if trimmed := strings.TrimSpace(req.GalleryAlbumURL); trimmed != "" {
article.GalleryAlbumURL = trimmed
}
if len(req.GalleryPhotoIDs) > 0 {
article.GalleryPhotoIDs = strings.Join(req.GalleryPhotoIDs, ",")
}
// 14. Set YouTube fields
if trimmed := strings.TrimSpace(req.YouTubeVideoID); trimmed != "" {
article.YouTubeVideoID = trimmed
}
if trimmed := strings.TrimSpace(req.YouTubeVideoTitle); trimmed != "" {
article.YouTubeVideoTitle = trimmed
}
if trimmed := strings.TrimSpace(req.YouTubeVideoURL); trimmed != "" {
article.YouTubeVideoURL = trimmed
}
if trimmed := strings.TrimSpace(req.YouTubeVideoThumbnail); trimmed != "" {
article.YouTubeVideoThumbnail = trimmed
}
// 15. Set attachments (serialize to JSON string)
if len(req.Attachments) > 0 {
if b, err := json.Marshal(req.Attachments); err == nil {
article.Attachments = string(b)
}
}
// 15. Save to database
if err := ac.DB.Create(&article).Error; err != nil {
logger.Error("CreateArticle: Database error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Nelze vytvořit článek",
"details": err.Error(),
})
return
}
logger.Info("CreateArticle: Successfully created article ID=%d, slug=%s", article.ID, article.Slug)
// 16. Track file usage (async)
go func() {
fileTracker := services.NewFileTracker(ac.DB)
fileTracker.TrackArticleFiles(&article)
}()
// 17. Reload with associations for response
ac.DB.Preload("Author").Preload("Category").First(&article, article.ID)
// 18. Trigger prefetch cache update (async)
if published {
go func() {
base := getBaseURL()
logger.Info("CreateArticle: Triggering prefetch cache update for published article")
services.PrefetchOnce(base)
}()
}
// 19. Return success response
c.JSON(http.StatusCreated, article)
}
// getBaseURL returns the base URL for internal API calls (used for prefetch trigger)
func getBaseURL() string {
base := strings.TrimSpace(os.Getenv("PREFETCH_TARGET"))
if base == "" {
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "8080"
}
base = "http://127.0.0.1:" + port + "/api/v1"
}
return base
}
// Note: Helper functions makeSlug, computeEstimatedReadMinutes, and deriveSeoDescription
// are defined in base_controller.go and shared across the controllers package