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