mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #65
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
"net/http"
|
||||
"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" binding:"required"`
|
||||
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
|
||||
}
|
||||
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. Return success response
|
||||
c.JSON(http.StatusCreated, article)
|
||||
}
|
||||
|
||||
// Note: Helper functions makeSlug, computeEstimatedReadMinutes, and deriveSeoDescription
|
||||
// are defined in base_controller.go and shared across the controllers package
|
||||
Reference in New Issue
Block a user