Files
MyClub/internal/controllers/article_controller.go
T
Tomas Dvorak 8762bde4bf dev day #89
2025-11-11 10:29:30 +01:00

312 lines
9.6 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 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. 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