package controllers import ( "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"` } // 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 category = models.Category{Name: categoryName} 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 default image if empty imageURL := strings.TrimSpace(req.ImageURL) 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. 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