package controllers import ( "errors" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" database "goGin/app/database/config" "goGin/app/database/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // Post payloads type PostPayload struct { Title string `json:"title" binding:"required" form:"title"` Slug string `json:"slug" form:"slug"` Images string `json:"images" form:"images"` Content string `json:"content" form:"content"` CategoryIDs []uint `json:"category_ids" form:"category_ids"` TagNames []string `json:"tag_names" form:"tag_names"` } // Post CRUD // CreatePost godoc // @Summary Create a post // @Description Create a new blog post (supports multipart/form-data with image upload) // @Tags posts // @Security BearerAuth // @Accept multipart/form-data // @Produce json // @Param title formData string true "Title" // @Param slug formData string false "Slug" // @Param content formData string false "Content" // @Param category_ids formData []int false "Category IDs (repeatable)" // @Param tag_names formData []string false "Tag names (repeatable)" // @Param images formData file false "Image files (use 'images' or 'image' fields)" // @Param width formData int false "Image width" // @Param height formData int false "Image height" // @Param quality formData int false "Image quality" // @Param format formData string false "Image format" // @Success 201 {object} controllers.PostResponse // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts [post] func CreatePost(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } // Determine whether request is multipart/form-data reliably. contentType := c.GetHeader("Content-Type") isMultipart := false if contentType != "" { if strings.Contains(contentType, "multipart/form-data") { isMultipart = true } } // Also try parsing MultipartForm to be robust if header is missing/varies if !isMultipart { if _, err := c.MultipartForm(); err == nil { isMultipart = true } } var payload PostPayload var width, height, quality int var format string var imagePaths []string if isMultipart { // parse basic fields from form payload.Title = c.PostForm("title") if payload.Title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) return } payload.Slug = c.PostForm("slug") payload.Content = c.PostForm("content") // parse repeated form fields catStrs := c.PostFormArray("category_ids") for _, s := range catStrs { if s == "" { continue } if id, err := strconv.Atoi(s); err == nil && id > 0 { payload.CategoryIDs = append(payload.CategoryIDs, uint(id)) } } payload.TagNames = c.PostFormArray("tag_names") // image metadata if v := c.PostForm("width"); v != "" { if wi, err := strconv.Atoi(v); err == nil { width = wi } } if v := c.PostForm("height"); v != "" { if hi, err := strconv.Atoi(v); err == nil { height = hi } } if v := c.PostForm("quality"); v != "" { if qi, err := strconv.Atoi(v); err == nil { quality = qi } } format = c.PostForm("format") // handle file uploads (support multiple files under 'images' or single 'image') if form, err := c.MultipartForm(); err == nil && form != nil { files := form.File["images"] if len(files) == 0 { files = form.File["image"] } if len(files) > 0 { uploadDir := filepath.Join("uploads", "posts") if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) return } for i, file := range files { ext := filepath.Ext(file.Filename) newName := fmt.Sprintf("post-%d-%d%s", time.Now().UnixNano(), i, ext) destination := filepath.Join(uploadDir, newName) if err := c.SaveUploadedFile(file, destination); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } imagePaths = append(imagePaths, "/uploads/posts/"+newName) } } } // if client provided image paths as form string if imgStr := c.PostForm("images"); imgStr != "" && len(imagePaths) == 0 { // accept comma separated paths for _, p := range strings.Split(imgStr, ",") { p = strings.TrimSpace(p) if p != "" { imagePaths = append(imagePaths, p) } } } } else { // JSON path if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // If JSON includes width/height/quality/format fields, map them // Try reading them from payload.Images if encoded or rely on frontend sending them in payload (not implemented here) } post := models.Post{ Title: payload.Title, Slug: payload.Slug, Images: strings.Join(imagePaths, ","), Content: payload.Content, Width: width, Height: height, Quality: quality, Format: format, } // Transaction and associations same as before tx := database.DB.Begin() if tx.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) return } if err := tx.Create(&post).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Attach categories if provided if len(payload.CategoryIDs) > 0 { var cats []models.Category if err := tx.Where("id IN ?", payload.CategoryIDs).Find(&cats).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Model(&post).Association("Categories").Replace(&cats); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } // Attach tags (create if not exists) if len(payload.TagNames) > 0 { var tags []models.Tag for _, name := range payload.TagNames { if name == "" { continue } var tag models.Tag if err := tx.Where("name = ?", name).First(&tag).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { tag = models.Tag{Name: name} if err := tx.Create(&tag).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } else { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } tags = append(tags, tag) } if err := tx.Model(&post).Association("Tags").Replace(&tags); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) return } // reload with associations if err := database.DB.Preload("Categories").Preload("Tags").First(&post, post.ID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": post}) } // GetPost godoc // @Summary Get a post by slug // @Description Return a single post found by slug // @Tags posts // @Produce json // @Param slug path string true "Post slug" // @Success 200 {object} controllers.PostResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/posts/{slug} [get] func GetPost(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } // slug param (router uses :slug) slug := c.Param("slug") if slug == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slug"}) return } var post models.Post if err := database.DB.Preload("Categories").Preload("Tags").Where("slug = ?", slug).First(&post).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": post}) } // ListPosts godoc // @Summary List posts // @Description List posts with pagination and optional filters // @Tags posts // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Param category_id query int false "Filter by category id" // @Param tag_id query int false "Filter by tag id" // @Param q query string false "Search query" // @Success 200 {object} controllers.PostListResponse // @Failure 500 {object} map[string]string // @Router /api/v1/posts [get] func ListPosts(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 100 { perPage = 100 } offset := (page - 1) * perPage query := database.DB.Model(&models.Post{}).Preload("Categories").Preload("Tags") // filters if catStr := c.Query("category_id"); catStr != "" { if catID, err := strconv.Atoi(catStr); err == nil && catID > 0 { query = query.Joins("JOIN post_categories pc ON pc.post_id = posts.id").Where("pc.category_id = ?", catID) } } if tagStr := c.Query("tag_id"); tagStr != "" { if tagID, err := strconv.Atoi(tagStr); err == nil && tagID > 0 { query = query.Joins("JOIN post_tags pt ON pt.post_id = posts.id").Where("pt.tag_id = ?", tagID) } } if q := c.Query("q"); q != "" { like := "%" + q + "%" query = query.Where("title LIKE ? OR content LIKE ?", like, like) } var total int64 if err := query.Distinct("posts.id").Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var posts []models.Post if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&posts).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"items": posts, "total": total, "page": page, "per_page": perPage}) } // Admin: List posts (supports soft-delete filter) // AdminListPosts godoc // @Summary Admin: List posts (supports soft-delete filter) // @Description Admin listing of posts. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. // @Tags posts // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Param category_id query int false "Filter by category id" // @Param tag_id query int false "Filter by tag id" // @Param q query string false "Search query" // @Param soft query string false "Soft delete filter: only|with" // @Success 200 {object} controllers.PostListResponse // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts [get] func AdminListPosts(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage soft := c.Query("soft") var query *gorm.DB if soft == "only" { query = database.DB.Unscoped().Model(&models.Post{}).Where("deleted_at IS NOT NULL") } else if soft == "with" { query = database.DB.Unscoped().Model(&models.Post{}) } else { query = database.DB.Model(&models.Post{}) } query = query.Preload("Categories").Preload("Tags") if catStr := c.Query("category_id"); catStr != "" { if catID, err := strconv.Atoi(catStr); err == nil && catID > 0 { query = query.Joins("JOIN post_categories pc ON pc.post_id = posts.id").Where("pc.category_id = ?", catID) } } if tagStr := c.Query("tag_id"); tagStr != "" { if tagID, err := strconv.Atoi(tagStr); err == nil && tagID > 0 { query = query.Joins("JOIN post_tags pt ON pt.post_id = posts.id").Where("pt.tag_id = ?", tagID) } } if q := c.Query("q"); q != "" { like := "%" + q + "%" query = query.Where("title LIKE ? OR content LIKE ?", like, like) } var total int64 if err := query.Distinct("posts.id").Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var posts []models.Post if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&posts).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"items": posts, "total": total, "page": page, "per_page": perPage}) } // AdminGetPost godoc // @Summary Admin: Get a post by id // @Description Return a single post by id with categories and tags // @Tags posts // @Security BearerAuth // @Produce json // @Param id path int true "Post ID" // @Success 200 {object} controllers.PostResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts/{id} [get] func AdminGetPost(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var post models.Post if err := database.DB.Unscoped().Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": post}) } // UpdatePost godoc // @Summary Update a post // @Description Update an existing blog post (supports multipart/form-data with image upload) // @Tags posts // @Security BearerAuth // @Accept multipart/form-data // @Produce json // @Param id path int true "Post ID" // @Param title formData string false "Title" // @Param slug formData string false "Slug" // @Param content formData string false "Content" // @Param category_ids formData []int false "Category IDs (repeatable)" // @Param tag_names formData []string false "Tag names (repeatable)" // @Param images formData file false "Image files (use 'images' or 'image' fields)" // @Param width formData int false "Image width" // @Param height formData int false "Image height" // @Param quality formData int false "Image quality" // @Param format formData string false "Image format" // @Success 200 {object} controllers.PostResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts/{id} [put] func UpdatePost(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } // id param (router uses :id) idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var post models.Post if err := database.DB.Where("id = ?", id).First(&post).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Determine multipart contentType := c.GetHeader("Content-Type") isMultipart := false if contentType != "" { if strings.Contains(contentType, "multipart/form-data") { isMultipart = true } } if !isMultipart { if _, err := c.MultipartForm(); err == nil { isMultipart = true } } var payload PostPayload var width, height, quality int var format string var imagePaths []string if isMultipart { // read optional fields and update only when provided if v := c.PostForm("title"); v != "" { post.Title = v } if v := c.PostForm("slug"); v != "" { post.Slug = v } if v := c.PostForm("content"); v != "" { post.Content = v } // categories catStrs := c.PostFormArray("category_ids") if len(catStrs) > 0 { var catIDs []uint for _, s := range catStrs { if s == "" { continue } if idn, err := strconv.Atoi(s); err == nil && idn > 0 { catIDs = append(catIDs, uint(idn)) } } payload.CategoryIDs = catIDs } // tags tagArr := c.PostFormArray("tag_names") if len(tagArr) > 0 { payload.TagNames = tagArr } // image metadata if v := c.PostForm("width"); v != "" { if wi, err := strconv.Atoi(v); err == nil { width = wi } } if v := c.PostForm("height"); v != "" { if hi, err := strconv.Atoi(v); err == nil { height = hi } } if v := c.PostForm("quality"); v != "" { if qi, err := strconv.Atoi(v); err == nil { quality = qi } } if v := c.PostForm("format"); v != "" { format = v } // handle file uploads if form, err := c.MultipartForm(); err == nil && form != nil { files := form.File["images"] if len(files) == 0 { files = form.File["image"] } if len(files) > 0 { uploadDir := filepath.Join("uploads", "posts") if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) return } for i, file := range files { ext := filepath.Ext(file.Filename) newName := fmt.Sprintf("post-%d-%d%s", time.Now().UnixNano(), i, ext) destination := filepath.Join(uploadDir, newName) if err := c.SaveUploadedFile(file, destination); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } imagePaths = append(imagePaths, "/uploads/posts/"+newName) } } } // if form provided images as string if imgStr := c.PostForm("images"); imgStr != "" && len(imagePaths) == 0 { for _, p := range strings.Split(imgStr, ",") { p = strings.TrimSpace(p) if p != "" { imagePaths = append(imagePaths, p) } } } // apply imagePaths if any if len(imagePaths) > 0 { post.Images = strings.Join(imagePaths, ",") } // apply image metadata if provided if width != 0 { post.Width = width } if height != 0 { post.Height = height } if quality != 0 { post.Quality = quality } if format != "" { post.Format = format } } else { // JSON path - keep previous behavior but allow partial updates if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if payload.Title != "" { post.Title = payload.Title } if payload.Slug != "" { post.Slug = payload.Slug } if payload.Content != "" { post.Content = payload.Content } if payload.Images != "" { post.Images = payload.Images } if len(payload.CategoryIDs) > 0 { /* handled below via payload */ } if len(payload.TagNames) > 0 { /* handled below via payload */ } } // Transaction tx := database.DB.Begin() if tx.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) return } if err := tx.Save(&post).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update categories if len(payload.CategoryIDs) > 0 { // filter out invalid/zero ids var filtered []uint for _, cid := range payload.CategoryIDs { if cid > 0 { filtered = append(filtered, cid) } } if len(filtered) > 0 { var cats []models.Category if err := tx.Where("id IN ?", filtered).Find(&cats).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Model(&post).Association("Categories").Replace(&cats); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } else { // filtered list empty -> do nothing (avoid clearing categories when client sends [0]) } } else { // If no categories provided in payload and request was multipart, do nothing (keep existing) } // Update tags if len(payload.TagNames) > 0 { var tags []models.Tag for _, name := range payload.TagNames { if name == "" { continue } var tag models.Tag if err := tx.Where("name = ?", name).First(&tag).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { tag = models.Tag{Name: name} if err := tx.Create(&tag).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } else { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } tags = append(tags, tag) } if err := tx.Model(&post).Association("Tags").Replace(&tags); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) return } // reload with associations if err := database.DB.Preload("Categories").Preload("Tags").First(&post, post.ID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": post}) } // DeletePost godoc // @Summary Delete a post // @Description Delete a blog post by ID // @Tags posts // @Security BearerAuth // @Produce json // @Param id path int true "Post ID" // @Success 204 {object} nil // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts/{id} [delete] func DeletePost(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } // id param (router uses :id) idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var post models.Post if err := database.DB.Where("id = ?", id).First(&post).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Delete the post (soft delete if model has DeletedAt) if err := database.DB.Delete(&post).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Return a helpful JSON response instead of empty body c.JSON(http.StatusOK, gin.H{ "message": "post deleted successfully", "id": post.ID, }) } // Category CRUD func GetCategory(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } // slug param slug := c.Param("slug") if slug == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slug"}) return } var cat models.Category if err := database.DB.Preload("Posts").Where("slug = ?", slug).First(&cat).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": cat}) } // Payloads for other models type CategoryPayload struct { Title string `json:"title" binding:"required"` Slug string `json:"slug"` Description string `json:"description"` ParentID *uint `json:"parent_id"` } type TagPayload struct { Name string `json:"name" binding:"required"` } type CommentPayload struct { UserID uint `json:"user_id" binding:"required"` PostID uint `json:"post_id" binding:"required"` Body string `json:"body" binding:"required"` } type CategoryViewPayload struct { CategoryID uint `json:"category_id" binding:"required"` IPAddress string `json:"ip_address"` } // Admin: CreateCategory // CreateCategory godoc // @Summary Create a category // @Description Create a new category (admin) // @Tags categories // @Security BearerAuth // @Accept json // @Produce json // @Param category body CategoryPayload true "Category payload" // @Success 201 {object} controllers.CategorySimple // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categories [post] func CreateCategory(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } var payload CategoryPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } cat := models.Category{Title: payload.Title, Slug: payload.Slug, Description: payload.Description, ParentID: payload.ParentID} if err := database.DB.Create(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } resp := CategorySimple{ID: cat.ID, Title: cat.Title, Slug: cat.Slug, ParentID: cat.ParentID} c.JSON(http.StatusCreated, gin.H{"data": resp}) } // Admin: UpdateCategory // UpdateCategory godoc // @Summary Update a category // @Description Update an existing category (admin) // @Tags categories // @Security BearerAuth // @Accept json // @Produce json // @Param id path int true "Category ID" // @Param category body CategoryPayload true "Category payload" // @Success 200 {object} controllers.CategorySimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categories/{id} [put] func UpdateCategory(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var payload CategoryPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var cat models.Category if err := database.DB.First(&cat, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } cat.Title = payload.Title cat.Slug = payload.Slug cat.Description = payload.Description cat.ParentID = payload.ParentID if err := database.DB.Save(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } resp := CategorySimple{ID: cat.ID, Title: cat.Title, Slug: cat.Slug, ParentID: cat.ParentID} c.JSON(http.StatusOK, gin.H{"data": resp}) } // Admin: DeleteCategory // DeleteCategory godoc // @Summary Delete a category // @Description Soft-delete a category (admin) // @Tags categories // @Security BearerAuth // @Produce json // @Param id path int true "Category ID" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categories/{id} [delete] func DeleteCategory(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cat models.Category if err := database.DB.First(&cat, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := database.DB.Delete(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "category deleted successfully", "id": cat.ID, }) } // Public: GetTag // GetTag godoc // @Summary Get a tag by id // @Description Return a single tag by id // @Tags tags // @Produce json // @Param id path int true "Tag ID" // @Success 200 {object} controllers.TagSimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/tags/{id} [get] func GetTag(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var t models.Tag if err := database.DB.First(&t, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": TagSimple{ID: t.ID, Name: t.Name}}) } // Admin: CreateTag // CreateTag godoc // @Summary Create a tag // @Description Create a new tag (admin) // @Tags tags // @Security BearerAuth // @Accept json // @Produce json // @Param tag body TagPayload true "Tag payload" // @Success 201 {object} controllers.TagSimple // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/tags [post] func CreateTag(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } var payload TagPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tag := models.Tag{Name: payload.Name} if err := database.DB.Create(&tag).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": TagSimple{ID: tag.ID, Name: tag.Name}}) } // Admin: UpdateTag // UpdateTag godoc // @Summary Update a tag // @Description Update an existing tag (admin) // @Tags tags // @Security BearerAuth // @Accept json // @Produce json // @Param id path int true "Tag ID" // @Param tag body TagPayload true "Tag payload" // @Success 200 {object} controllers.TagSimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/tags/{id} [put] func UpdateTag(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var payload TagPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var tag models.Tag if err := database.DB.First(&tag, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } tag.Name = payload.Name if err := database.DB.Save(&tag).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": TagSimple{ID: tag.ID, Name: tag.Name}}) } // Admin: DeleteTag // DeleteTag godoc // @Summary Delete a tag // @Description Soft-delete a tag (admin) // @Tags tags // @Security BearerAuth // @Produce json // @Param id path int true "Tag ID" // @Success 204 {object} nil // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/tags/{id} [delete] func DeleteTag(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var tag models.Tag if err := database.DB.First(&tag, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := database.DB.Delete(&tag).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.Status(http.StatusNoContent) } // RestoreTag godoc // @Summary Restore a soft-deleted tag // @Description Restore a tag that has been soft-deleted (admin) // @Tags tags // @Security BearerAuth // @Produce json // @Param id path int true "Tag ID" // @Success 200 {object} controllers.TagSimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/tags/{id}/restore [post] func RestoreTag(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var tag models.Tag if err := database.DB.Unscoped().First(&tag, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if tag.DeletedAt.Valid { if err := database.DB.Unscoped().Model(&tag).Update("deleted_at", nil).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } c.JSON(http.StatusOK, gin.H{"data": TagSimple{ID: tag.ID, Name: tag.Name}}) } // Public: GetComment // GetComment godoc // @Summary Get a comment by id // @Description Return a single comment by id // @Tags comments // @Produce json // @Param id path int true "Comment ID" // @Success 200 {object} controllers.CommentSimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/comments/{id} [get] func GetComment(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cm models.Comment if err := database.DB.First(&cm, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) } // Admin: CreateComment // CreateComment godoc // @Summary Create a comment (admin) // @Description Create a comment as admin // @Tags comments // @Security BearerAuth // @Accept json // @Produce json // @Param comment body CommentPayload true "Comment payload" // @Success 201 {object} controllers.CommentSimple // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/comments [post] func CreateComment(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } var payload CommentPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } cm := models.Comment{UserID: payload.UserID, PostID: payload.PostID, Body: payload.Body} if err := database.DB.Create(&cm).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) } // Admin: UpdateComment // UpdateComment godoc // @Summary Update a comment (admin) // @Description Update a comment as admin // @Tags comments // @Security BearerAuth // @Accept json // @Produce json // @Param id path int true "Comment ID" // @Param comment body CommentPayload true "Comment payload" // @Success 200 {object} controllers.CommentSimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/comments/{id} [put] func UpdateComment(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var payload CommentPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var cm models.Comment if err := database.DB.First(&cm, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } cm.Body = payload.Body if err := database.DB.Save(&cm).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) } // Admin: DeleteComment // DeleteComment godoc // @Summary Delete a comment (admin) // @Description Soft-delete a comment as admin // @Tags comments // @Security BearerAuth // @Produce json // @Param id path int true "Comment ID" // @Success 204 {object} nil // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/comments/{id} [delete] func DeleteComment(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cm models.Comment if err := database.DB.First(&cm, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := database.DB.Delete(&cm).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.Status(http.StatusNoContent) } // Public: GetCategoryView // GetCategoryView godoc // @Summary Get a category view by id // @Description Return a single category view by id // @Tags categoryviews // @Produce json // @Param id path int true "CategoryView ID" // @Success 200 {object} controllers.CategoryViewSimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/categoryviews/{id} [get] func GetCategoryView(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cv models.CategoryView if err := database.DB.First(&cv, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "category view not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": CategoryViewSimple{ID: cv.ID, CategoryID: cv.CategoryID, IPAddress: cv.IPAddress, Created: cv.CreatedAt}}) } // Admin: CreateCategoryView // CreateCategoryView godoc // @Summary Create a category view (admin) // @Description Create a category view as admin // @Tags categoryviews // @Security BearerAuth // @Accept json // @Produce json // @Param view body CategoryViewPayload true "CategoryView payload" // @Success 201 {object} controllers.CategoryViewSimple // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categoryviews [post] func CreateCategoryView(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } var payload CategoryViewPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } cv := models.CategoryView{CategoryID: payload.CategoryID, IPAddress: payload.IPAddress} if err := database.DB.Create(&cv).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": CategoryViewSimple{ID: cv.ID, CategoryID: cv.CategoryID, IPAddress: cv.IPAddress, Created: cv.CreatedAt}}) } // İlişkili işlemler func FilterPostsByTag(c *gin.Context) { _ = c } // ListDeletedPosts godoc // @Summary List soft-deleted posts // @Description List posts that have been soft-deleted with pagination // @Tags posts // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Success 200 {object} controllers.PostListResponse // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts/deleted [get] func ListDeletedPosts(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage query := database.DB.Unscoped().Model(&models.Post{}).Where("deleted_at IS NOT NULL").Preload("Categories").Preload("Tags") var total int64 if err := query.Distinct("posts.id").Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var posts []models.Post if err := query.Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&posts).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var respItems []PostResponse for _, p := range posts { var cats []CategorySimple for _, cc := range p.Categories { cats = append(cats, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) } var tags []TagSimple for _, t := range p.Tags { tags = append(tags, TagSimple{ID: t.ID, Name: t.Name}) } respItems = append(respItems, PostResponse{ ID: p.ID, Title: p.Title, Slug: p.Slug, Images: p.Images, Content: p.Content, Categories: cats, Tags: tags, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, }) } c.JSON(http.StatusOK, gin.H{"items": respItems, "total": total, "page": page, "per_page": perPage}) } // RestorePost godoc // @Summary Restore a soft-deleted post // @Description Restore a post and its related comments (if soft-deleted) // @Tags posts // @Security BearerAuth // @Produce json // @Param id path int true "Post ID" // @Success 200 {object} controllers.PostResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts/{id}/restore [post] func RestorePost(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } tx := database.DB.Begin() if tx.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) return } var post models.Post if err := tx.Unscoped().Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if post.DeletedAt.Time.IsZero() { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{"error": "post is not deleted"}) return } // restore post if err := tx.Unscoped().Model(&post).Update("deleted_at", nil).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // restore related comments (if any) if err := tx.Unscoped().Model(&models.Comment{}).Where("post_id = ? AND deleted_at IS NOT NULL", post.ID).Update("deleted_at", nil).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) return } if err := database.DB.Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var cats []CategorySimple for _, cc := range post.Categories { cats = append(cats, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) } var tags []TagSimple for _, t := range post.Tags { tags = append(tags, TagSimple{ID: t.ID, Name: t.Name}) } resp := PostResponse{ID: post.ID, Title: post.Title, Slug: post.Slug, Images: post.Images, Content: post.Content, Categories: cats, Tags: tags, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt} c.JSON(http.StatusOK, gin.H{"data": resp}) } // ListDeletedCategories godoc // @Summary List soft-deleted categories // @Description List categories that have been soft-deleted with pagination // @Tags categories // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categories/deleted [get] func ListDeletedCategories(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage query := database.DB.Unscoped().Model(&models.Category{}).Where("deleted_at IS NOT NULL") var total int64 if err := query.Distinct("categories.id").Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var cats []models.Category if err := query.Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&cats).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []CategorySimple for _, cc := range cats { resp = append(resp, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // RestoreCategory godoc // @Summary Restore a soft-deleted category // @Description Restore a category and related posts/comments if soft-deleted // @Tags categories // @Security BearerAuth // @Produce json // @Param id path int true "Category ID" // @Success 200 {object} controllers.CategorySimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categories/{id}/restore [post] func RestoreCategory(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } tx := database.DB.Begin() if tx.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) return } var cat models.Category if err := tx.Unscoped().First(&cat, id).Error; err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if cat.DeletedAt.Time.IsZero() { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{"error": "category is not deleted"}) return } // restore category if err := tx.Unscoped().Model(&cat).Update("deleted_at", nil).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // find post ids related to this category that are soft-deleted var postIDs []uint if err := tx.Table("posts").Select("posts.id").Joins("JOIN post_categories pc ON pc.post_id = posts.id").Where("pc.category_id = ? AND posts.deleted_at IS NOT NULL", cat.ID).Scan(&postIDs).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(postIDs) > 0 { // restore posts if err := tx.Unscoped().Model(&models.Post{}).Where("id IN ?", postIDs).Update("deleted_at", nil).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // restore comments for those posts if err := tx.Unscoped().Model(&models.Comment{}).Where("post_id IN ? AND deleted_at IS NOT NULL", postIDs).Update("deleted_at", nil).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) return } resp := CategorySimple{ID: cat.ID, Title: cat.Title, Slug: cat.Slug, ParentID: cat.ParentID} c.JSON(http.StatusOK, gin.H{"data": resp}) } // ListCategories godoc // @Summary List categories // @Description List categories with pagination // @Tags categories // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/categories [get] func ListCategories(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage var total int64 if err := database.DB.Model(&models.Category{}).Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var cats []models.Category if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&cats).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []CategorySimple for _, cc := range cats { resp = append(resp, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // AdminListCategories godoc // @Summary Admin: List categories (supports soft-delete filter) // @Description Admin listing of categories. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. // @Tags categories // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Param soft query string false "Soft delete filter: only|with" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categories [get] func AdminListCategories(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage soft := c.Query("soft") var query *gorm.DB if soft == "only" { query = database.DB.Unscoped().Model(&models.Category{}).Where("deleted_at IS NOT NULL") } else if soft == "with" { query = database.DB.Unscoped().Model(&models.Category{}) } else { query = database.DB.Model(&models.Category{}) } query = query.Table("categories") var total int64 if err := query.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []struct { ID uint `gorm:"column:id"` Title string `gorm:"column:title"` Slug string `gorm:"column:slug"` ParentID *uint `gorm:"column:parent_id"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` } if err := query.Select("id", "title", "slug", "parent_id", "deleted_at").Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []AdminCategoryListItem for _, v := range items { item := AdminCategoryListItem{ID: v.ID, Title: v.Title, Slug: v.Slug, ParentID: v.ParentID} if v.DeletedAt.Valid { item.DeletedAt = &v.DeletedAt.Time } resp = append(resp, item) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // ListTags godoc // @Summary List tags // @Description List tags with pagination // @Tags tags // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/tags [get] func ListTags(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage var total int64 if err := database.DB.Model(&models.Tag{}).Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []models.Tag if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []TagSimple for _, v := range items { resp = append(resp, TagSimple{ID: v.ID, Name: v.Name}) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // AdminListTags godoc // @Summary Admin: List tags (supports soft-delete filter) // @Description Admin listing of tags. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. // @Tags tags // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Param soft query string false "Soft delete filter: only|with" // @Success 200 {object} map[string]interface{} "items: []AdminTagListItem (includes deleted_at when soft=only or soft=with)" // @Failure 500 {object} map[string]string // @Router /api/v1/admin/tags [get] func AdminListTags(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage soft := c.Query("soft") var query *gorm.DB if soft == "only" { query = database.DB.Unscoped().Model(&models.Tag{}).Where("deleted_at IS NOT NULL") } else if soft == "with" { query = database.DB.Unscoped().Model(&models.Tag{}) } else { query = database.DB.Model(&models.Tag{}) } query = query.Table("tags") var total int64 if err := query.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []struct { ID uint `gorm:"column:id"` Name string `gorm:"column:name"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` } if err := query.Select("id", "name", "deleted_at").Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []AdminTagListItem for _, v := range items { item := AdminTagListItem{ID: v.ID, Name: v.Name} if v.DeletedAt.Valid { item.DeletedAt = &v.DeletedAt.Time } resp = append(resp, item) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // ListComments godoc // @Summary List comments // @Description List comments with pagination // @Tags comments // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/comments [get] func ListComments(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage var total int64 if err := database.DB.Model(&models.Comment{}).Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []models.Comment if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []CommentSimple for _, v := range items { resp = append(resp, CommentSimple{ID: v.ID, UserID: v.UserID, PostID: v.PostID, Body: v.Body, Created: v.CreatedAt}) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // AdminListComments godoc // @Summary Admin: List comments (supports soft-delete filter) // @Description Admin listing of comments. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. // @Tags comments // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Param soft query string false "Soft delete filter: only|with" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/admin/comments [get] func AdminListComments(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage soft := c.Query("soft") var query *gorm.DB if soft == "only" { query = database.DB.Unscoped().Model(&models.Comment{}).Where("deleted_at IS NOT NULL") } else if soft == "with" { query = database.DB.Unscoped().Model(&models.Comment{}) } else { query = database.DB.Model(&models.Comment{}) } var total int64 if err := query.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []models.Comment if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []CommentSimple for _, v := range items { resp = append(resp, CommentSimple{ID: v.ID, UserID: v.UserID, PostID: v.PostID, Body: v.Body, Created: v.CreatedAt}) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // ListCategoryViews godoc // @Summary List category views // @Description List category views with pagination // @Tags categoryviews // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/categoryviews [get] func ListCategoryViews(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage var total int64 if err := database.DB.Model(&models.CategoryView{}).Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []models.CategoryView if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []CategoryViewSimple for _, v := range items { resp = append(resp, CategoryViewSimple{ID: v.ID, CategoryID: v.CategoryID, IPAddress: v.IPAddress, Created: v.CreatedAt}) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // AdminListCategoryViews godoc // @Summary Admin: List category views (supports soft-delete filter) // @Description Admin listing of category views. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. // @Tags categoryviews // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Param soft query string false "Soft delete filter: only|with" // @Success 200 {object} map[string]interface{} // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categoryviews [get] func AdminListCategoryViews(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage soft := c.Query("soft") var query *gorm.DB if soft == "only" { query = database.DB.Unscoped().Model(&models.CategoryView{}).Where("deleted_at IS NOT NULL") } else if soft == "with" { query = database.DB.Unscoped().Model(&models.CategoryView{}) } else { query = database.DB.Model(&models.CategoryView{}) } var total int64 if err := query.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []models.CategoryView if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var resp []CategoryViewSimple for _, v := range items { resp = append(resp, CategoryViewSimple{ID: v.ID, CategoryID: v.CategoryID, IPAddress: v.IPAddress, Created: v.CreatedAt}) } c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) } // Admin: AddCommentToPost // AddCommentToPost godoc // @Summary Add a comment to a post (admin) // @Description Add a comment to a specific post as admin // @Tags posts // @Security BearerAuth // @Accept json // @Produce json // @Param id path int true "Post ID" // @Param comment body CommentPayload true "Comment payload" // @Success 201 {object} controllers.CommentSimple // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/posts/{id}/comments [post] func AddCommentToPost(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid post id"}) return } var payload CommentPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var post models.Post if err := database.DB.First(&post, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } cm := models.Comment{UserID: payload.UserID, PostID: uint(id), Body: payload.Body} if err := database.DB.Create(&cm).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) } // Admin: AddPostToCategory // AddPostToCategory godoc // @Summary Add a post to a category (admin) // @Description Create a post and attach it to the given category // @Tags categories // @Security BearerAuth // @Accept json // @Produce json // @Param id path int true "Category ID" // @Param post body PostPayload true "Post payload" // @Success 201 {object} controllers.PostResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/categories/{id}/posts [post] func AddPostToCategory(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } catIDStr := c.Param("id") catID, err := strconv.Atoi(catIDStr) if err != nil || catID < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid category id"}) return } var payload PostPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var cat models.Category if err := database.DB.First(&cat, catID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } post := models.Post{Title: payload.Title, Slug: payload.Slug, Images: payload.Images, Content: payload.Content} tx := database.DB.Begin() if tx.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) return } if err := tx.Create(&post).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Model(&post).Association("Categories").Append(&cat); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(payload.CategoryIDs) > 0 { var cats []models.Category if err := tx.Where("id IN ?", payload.CategoryIDs).Find(&cats).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Model(&post).Association("Categories").Replace(&cats); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if len(payload.TagNames) > 0 { var tags []models.Tag for _, name := range payload.TagNames { if name == "" { continue } var tag models.Tag if err := tx.Where("name = ?", name).First(&tag).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { tag = models.Tag{Name: name} if err := tx.Create(&tag).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } else { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } tags = append(tags, tag) } if err := tx.Model(&post).Association("Tags").Replace(&tags); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) return } if err := database.DB.Preload("Categories").Preload("Tags").First(&post, post.ID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var catsResp []CategorySimple for _, cc := range post.Categories { catsResp = append(catsResp, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) } var tagsResp []TagSimple for _, t := range post.Tags { tagsResp = append(tagsResp, TagSimple{ID: t.ID, Name: t.Name}) } resp := PostResponse{ID: post.ID, Title: post.Title, Slug: post.Slug, Images: post.Images, Content: post.Content, Categories: catsResp, Tags: tagsResp, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt} c.JSON(http.StatusCreated, gin.H{"data": resp}) }