mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
337 lines
11 KiB
Go
337 lines
11 KiB
Go
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
|