Files
goFiber/controllers/blog_controller.go
Beyhan Oğur 60db80892b first commit
2026-04-26 21:45:19 +03:00

1365 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"encoding/json"
"errors"
"fmt"
database "goFiber/database/config"
"goFiber/database/models"
"mime/multipart"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// reuse package-level `validate` defined in other controller files
// (one global validator instance is sufficient)
func parseIDsCSV(s string) []uint {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]uint, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
v, err := strconv.ParseUint(p, 10, 64)
if err == nil {
out = append(out, uint(v))
}
}
return out
}
// slugify converts a title to a URL-friendly slug
func slugify(s string) string {
// replace common Turkish characters with ASCII equivalents
replacer := strings.NewReplacer(
"Ç", "C", "ç", "c",
"Ğ", "G", "ğ", "g",
"İ", "I", "ı", "i",
"Ö", "O", "ö", "o",
"Ş", "S", "ş", "s",
"Ü", "U", "ü", "u",
)
s = replacer.Replace(s)
s = strings.ToLower(s)
re := regexp.MustCompile("[^a-z0-9]+")
s = re.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
// UpdateCategoryRequest represents payload for updating a category
// swagger:model UpdateCategoryRequest
type UpdateCategoryRequest struct {
Title *string `json:"title" validate:"omitempty,min=2"`
Description *string `json:"description"`
ParentID *uint `json:"parent_id"`
}
// makeUniqueSlug ensures the slug is unique in posts table, appending suffix if needed
func makeUniqueSlug(base string) string {
slug := base
var count int64
i := 1
for {
database.DB.Model(&models.Post{}).Where("slug = ?", slug).Count(&count)
if count == 0 {
return slug
}
slug = base + "-" + strconv.Itoa(i)
i++
}
}
// makeUniqueSlugExclude ensures uniqueness excluding a specific post id (used on updates)
func makeUniqueSlugExclude(base string, excludeID uint64) string {
slug := base
var count int64
i := 1
for {
database.DB.Model(&models.Post{}).Where("slug = ? AND id != ?", slug, excludeID).Count(&count)
if count == 0 {
return slug
}
slug = base + "-" + strconv.Itoa(i)
i++
}
}
// makeUniqueSlugCategory ensures the slug is unique in categories table
func makeUniqueSlugCategory(base string) string {
slug := base
var count int64
i := 1
for {
database.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&count)
if count == 0 {
return slug
}
slug = base + "-" + strconv.Itoa(i)
i++
}
}
// makeUniqueSlugCategoryExclude ensures the slug is unique in categories table excluding an id
func makeUniqueSlugCategoryExclude(base string, excludeID uint64) string {
slug := base
var count int64
i := 1
for {
database.DB.Model(&models.Category{}).Where("slug = ? AND id != ?", slug, excludeID).Count(&count)
if count == 0 {
return slug
}
slug = base + "-" + strconv.Itoa(i)
i++
}
}
func saveUploadedFiles(c fiber.Ctx, files []*multipart.FileHeader) ([]string, error) {
if len(files) == 0 {
return nil, nil
}
saved := make([]string, 0, len(files))
for _, fh := range files {
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), fh.Filename)
path := filepath.Join("./uploads/posts", filename)
if err := c.SaveFile(fh, path); err != nil {
return nil, err
}
saved = append(saved, "/uploads/posts/"+filename)
}
return saved, nil
}
// -------- POSTS --------
// GetPosts godoc
// @Summary List posts (public) with pagination
// @Tags Posts
// @Produce json
// @Param page query int false "Page number"
// @Param per_page query int false "Items per page"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/posts [get]
func GetPosts(c fiber.Ctx) error {
pageStr := c.Query("page", "1")
perPageStr := c.Query("per_page", "10")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
var total int64
database.DB.Model(&models.Post{}).Count(&total)
var posts []models.Post
database.DB.Preload("Categories").Preload("Tags").Limit(perPage).Offset(offset).Find(&posts)
return c.JSON(fiber.Map{
"data": posts,
"meta": fiber.Map{"page": page, "per_page": perPage, "total": total},
})
}
// GetPost godoc
// @Summary Get single post (public)
// @Tags Posts
// @Produce json
// @Param id path int true "Post ID"
// @Success 200 {object} models.PostDoc
// @Failure 404 {object} map[string]string
// @Router /api/v1/posts/{id} [get]
func GetPost(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var post models.Post
if err := database.DB.Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"})
}
return c.JSON(post)
}
// CreatePost godoc
// @Summary Create a post (admin only)
// @Tags Posts
// @Accept mpfd
// @Produce json
// @Security BearerAuth
// @Param title formData string true "Title"
// @Param content formData string false "Content"
// @Param category_ids formData string false "Comma separated category ids"
// @Param tag_ids formData string false "Comma separated tag ids"
// @Param images formData file false "Images (multiple allowed)"
// @Success 201 {object} models.PostDoc
// @Failure 400 {object} map[string]string
// @Router /api/v1/posts [post]
func CreatePost(c fiber.Ctx) error {
// Support both multipart/form-data and JSON
if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") {
form, err := c.MultipartForm()
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid multipart form"})
}
title := firstValue(form.Value, "title")
if title == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "title required"})
}
content := firstValue(form.Value, "content")
catIDs := parseIDsCSV(firstValue(form.Value, "category_ids"))
tagIDs := parseIDsCSV(firstValue(form.Value, "tag_ids"))
files := form.File["images"]
saved, err := saveUploadedFiles(c, files)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save images"})
}
imgsJSON, _ := json.Marshal(saved)
baseSlug := slugify(title)
post := models.Post{Title: title, Content: content, Images: string(imgsJSON)}
post.Slug = makeUniqueSlug(baseSlug)
if len(catIDs) > 0 {
var cats []models.Category
database.DB.Find(&cats, catIDs)
post.Categories = cats
}
if len(tagIDs) > 0 {
var tags []models.Tag
database.DB.Find(&tags, tagIDs)
post.Tags = tags
}
if err := database.DB.Create(&post).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create post"})
}
return c.Status(http.StatusCreated).JSON(post)
}
// JSON fallback
var input struct {
Title string `json:"title" validate:"required,min=3"`
Content string `json:"content"`
CategoryIDs []uint `json:"category_ids"`
TagIDs []uint `json:"tag_ids"`
Images []string `json:"images"`
}
if err := c.Bind().Body(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := validate.Struct(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
imgsJSON, _ := json.Marshal(input.Images)
baseSlug := slugify(input.Title)
post := models.Post{Title: input.Title, Content: input.Content, Images: string(imgsJSON)}
post.Slug = makeUniqueSlug(baseSlug)
if len(input.CategoryIDs) > 0 {
var cats []models.Category
database.DB.Find(&cats, input.CategoryIDs)
post.Categories = cats
}
if len(input.TagIDs) > 0 {
var tags []models.Tag
database.DB.Find(&tags, input.TagIDs)
post.Tags = tags
}
if err := database.DB.Create(&post).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create post"})
}
return c.Status(http.StatusCreated).JSON(post)
}
func firstValue(m map[string][]string, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok && len(v) > 0 {
return v[0]
}
return ""
}
// UpdatePost godoc
// @Summary Update a post (admin only)
// @Tags Posts
// @Accept mpfd
// @Produce json
// @Security BearerAuth
// @Param id path int true "Post ID"
// @Param title formData string false "Title"
// @Param content formData string false "Content"
// @Param category_ids formData string false "Comma separated category ids"
// @Param tag_ids formData string false "Comma separated tag ids"
// @Param images formData file false "Images (multiple allowed)"
// @Success 200 {object} models.PostDoc
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/posts/{id} [put]
func UpdatePost(c fiber.Ctx) error {
// multipart or JSON handling similar to CreatePost
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var post models.Post
if err := database.DB.Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"})
}
if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") {
form, err := c.MultipartForm()
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid multipart form"})
}
title := firstValue(form.Value, "title")
if title != "" {
post.Title = title
// regenerate slug on title change
post.Slug = makeUniqueSlugExclude(slugify(title), uint64(post.ID))
}
content := firstValue(form.Value, "content")
if content != "" {
post.Content = content
}
catIDs := parseIDsCSV(firstValue(form.Value, "category_ids"))
if len(catIDs) > 0 {
var cats []models.Category
database.DB.Find(&cats, catIDs)
if err := database.DB.Model(&post).Association("Categories").Replace(&cats); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update categories"})
}
}
tagIDs := parseIDsCSV(firstValue(form.Value, "tag_ids"))
if len(tagIDs) > 0 {
var tags []models.Tag
database.DB.Find(&tags, tagIDs)
if err := database.DB.Model(&post).Association("Tags").Replace(&tags); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update tags"})
}
}
files := form.File["images"]
if len(files) > 0 {
saved, err := saveUploadedFiles(c, files)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save images"})
}
imgsJSON, _ := json.Marshal(saved)
post.Images = string(imgsJSON)
}
if err := database.DB.Save(&post).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update post"})
}
return c.JSON(post)
}
// JSON fallback
var input struct {
Title *string `json:"title" validate:"omitempty,min=3"`
Content *string `json:"content"`
CategoryIDs []uint `json:"category_ids"`
TagIDs []uint `json:"tag_ids"`
Images []string `json:"images"`
}
if err := c.Bind().Body(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := validate.Struct(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
if input.Title != nil {
post.Title = *input.Title
post.Slug = makeUniqueSlugExclude(slugify(*input.Title), uint64(post.ID))
}
if input.Content != nil {
post.Content = *input.Content
}
if input.CategoryIDs != nil {
var cats []models.Category
database.DB.Find(&cats, input.CategoryIDs)
if err := database.DB.Model(&post).Association("Categories").Replace(&cats); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update categories"})
}
}
if input.TagIDs != nil {
var tags []models.Tag
database.DB.Find(&tags, input.TagIDs)
if err := database.DB.Model(&post).Association("Tags").Replace(&tags); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update tags"})
}
}
if input.Images != nil {
imgsJSON, _ := json.Marshal(input.Images)
post.Images = string(imgsJSON)
}
if err := database.DB.Save(&post).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update post"})
}
return c.JSON(post)
}
// DeletePost godoc
// @Summary Delete a post (admin only)
// @Tags Posts
// @Produce json
// @Security BearerAuth
// @Param id path int true "Post ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/posts/{id} [delete]
func DeletePost(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var post models.Post
if err := database.DB.First(&post, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"})
}
if err := database.DB.Delete(&post).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete post"})
}
return c.JSON(fiber.Map{"message": "post deleted"})
}
// AdminListPosts godoc
// @Summary List posts (admin) with optional trashed filter
// @Tags Posts
// @Produce json
// @Security BearerAuth
// @Param trashed query string false "Trash filter: none|only|with"
// @Param page query int false "Page number"
// @Param per_page query int false "Items per page"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Router /api/v1/admin/posts [get]
func AdminListPosts(c fiber.Ctx) error {
trashed := c.Query("trashed", "none")
if trashed != "none" && trashed != "only" && trashed != "with" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"})
}
pageStr := c.Query("page", "1")
perPageStr := c.Query("per_page", "10")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
var total int64
var posts []models.Post
db := database.DB
switch trashed {
case "none":
db.Model(&models.Post{}).Count(&total)
db.Preload("Categories").Preload("Tags").Order("id desc").Limit(perPage).Offset(offset).Find(&posts)
case "only":
db.Unscoped().Model(&models.Post{}).Where("deleted_at IS NOT NULL").Count(&total)
db.Unscoped().Preload("Categories").Preload("Tags").Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&posts)
case "with":
db.Unscoped().Model(&models.Post{}).Count(&total)
db.Unscoped().Preload("Categories").Preload("Tags").Order("id desc").Limit(perPage).Offset(offset).Find(&posts)
}
return c.JSON(fiber.Map{"data": posts, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}})
}
// HardDeletePost godoc
// @Summary Permanently delete a post (admin only)
// @Tags Posts
// @Produce json
// @Security BearerAuth
// @Param id path int true "Post ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/posts/{id}/hard [delete]
func HardDeletePost(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid post id"})
}
var post models.Post
if err := database.DB.Unscoped().First(&post, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
err = database.DB.Transaction(func(tx *gorm.DB) error {
// clear many2many associations to avoid orphaned join rows
if err := tx.Model(&post).Association("Categories").Clear(); err != nil {
return err
}
if err := tx.Model(&post).Association("Tags").Clear(); err != nil {
return err
}
// permanently delete the post
return tx.Unscoped().Delete(&post).Error
})
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "post hard-delete failed"})
}
return c.JSON(fiber.Map{
"message": "post permanently deleted",
"post_id": id,
})
}
// AdminRestorePost godoc
// @Summary Restore soft-deleted post (admin only)
// @Tags Posts
// @Produce json
// @Security BearerAuth
// @Param id path int true "Post ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/posts/{id}/restore [post]
func AdminRestorePost(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid post id"})
}
var post models.Post
if err := database.DB.Unscoped().First(&post, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
// Check if soft-deleted
if !post.DeletedAt.Valid {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "post is not soft-deleted"})
}
if err := database.DB.Unscoped().Model(&models.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "post could not be restored"})
}
return c.JSON(fiber.Map{
"message": "post restored successfully",
"post_id": id,
})
}
// -------- CATEGORIES --------
// ListCategories godoc
// @Summary List categories (public)
// @Tags Categories
// @Produce json
// @Success 200 {array} models.CategoryDoc
// @Router /api/v1/categories [get]
func ListCategories(c fiber.Ctx) error {
var cats []models.Category
// return only root categories (no parent) and preload their immediate children
database.DB.Preload("Children").Where("parent_id IS NULL").Find(&cats)
return c.JSON(cats)
}
// helper: convert models.Category -> models.CategoryDoc recursively
func categoryToDoc(cat models.Category) models.CategoryDoc {
doc := models.CategoryDoc{
ID: uint(cat.ID),
Title: cat.Title,
Description: cat.Description,
ParentID: cat.ParentID,
}
if len(cat.Children) > 0 {
children := make([]models.CategoryDoc, 0, len(cat.Children))
for _, ch := range cat.Children {
children = append(children, categoryToDoc(ch))
}
doc.Children = children
}
return doc
}
// GetCategory godoc
// @Summary Get single category (public)
// @Tags Categories
// @Produce json
// @Param id path int true "Category ID"
// @Success 200 {object} models.CategoryDoc
// @Failure 404 {object} map[string]string
// @Router /api/v1/categories/{id} [get]
func GetCategory(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var cat models.Category
if err := database.DB.Preload("Children").First(&cat, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"})
}
// load children recursively if needed
// (Preload("Children") will load one level; deeper nesting can be loaded if required)
doc := categoryToDoc(cat)
return c.JSON(doc)
}
// CreateCategoryRequest represents payload for creating a category
type CreateCategoryRequest struct {
Title string `json:"title" validate:"required,min=2"`
Description string `json:"description,omitempty"`
ParentID *uint `json:"parent_id,omitempty"`
}
// CreateCategory godoc
// @Summary Create category (admin only)
// @Tags Categories
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body CreateCategoryRequest true "Category payload"
// @Success 201 {object} models.CategoryDoc
// @Failure 400 {object} map[string]string
// @Router /api/v1/categories [post]
func CreateCategory(c fiber.Ctx) error {
var input CreateCategoryRequest
if err := c.Bind().Body(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := validate.Struct(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
// validate parent exists when provided
if input.ParentID != nil {
var parent models.Category
if err := database.DB.First(&parent, *input.ParentID).Error; err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid parent_id"})
}
}
cat := models.Category{Title: input.Title, Description: input.Description, ParentID: input.ParentID}
// generate unique slug from title
base := slugify(input.Title)
cat.Slug = makeUniqueSlugCategory(base)
if err := database.DB.Create(&cat).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create category"})
}
return c.Status(http.StatusCreated).JSON(cat)
}
// UpdateCategory godoc
// @Summary Update category (admin only)
// @Tags Categories
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Category ID"
// @Param data body UpdateCategoryRequest true "Category payload"
// @Success 200 {object} models.CategoryDoc
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/categories/{id} [put]
func UpdateCategory(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var cat models.Category
if err := database.DB.First(&cat, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"})
}
var input struct {
Title *string `json:"title" validate:"omitempty,min=2"`
Description *string `json:"description"`
ParentID *uint `json:"parent_id"`
}
if err := c.Bind().Body(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := validate.Struct(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
if input.Title != nil {
cat.Title = *input.Title
// regenerate slug on title change
cat.Slug = makeUniqueSlugCategoryExclude(slugify(*input.Title), uint64(cat.ID))
}
if input.Description != nil {
cat.Description = *input.Description
}
if input.ParentID != nil {
// prevent setting parent to itself
if *input.ParentID == cat.ID {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "parent_id cannot be the category itself"})
}
// validate parent exists
var parent models.Category
if err := database.DB.First(&parent, *input.ParentID).Error; err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid parent_id"})
}
cat.ParentID = input.ParentID
}
if err := database.DB.Save(&cat).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update category"})
}
return c.JSON(cat)
}
// DeleteCategory godoc
// @Summary Delete category (admin only)
// @Tags Categories
// @Produce json
// @Security BearerAuth
// @Param id path int true "Category ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/categories/{id} [delete]
func DeleteCategory(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var cat models.Category
if err := database.DB.First(&cat, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"})
}
if err := database.DB.Delete(&cat).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete category"})
}
return c.JSON(fiber.Map{"message": "category deleted"})
}
// AdminListCategories godoc
// @Summary List categories (admin) with optional trashed filter
// @Tags Categories
// @Produce json
// @Security BearerAuth
// @Param trashed query string false "Trash filter: none|only|with"
// @Param page query int false "Page number"
// @Param per_page query int false "Items per page"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Router /api/v1/admin/categories [get]
func AdminListCategories(c fiber.Ctx) error {
trashed := c.Query("trashed", "none")
if trashed != "none" && trashed != "only" && trashed != "with" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"})
}
pageStr := c.Query("page", "1")
perPageStr := c.Query("per_page", "10")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
var total int64
var cats []models.Category
db := database.DB
switch trashed {
case "none":
db.Model(&models.Category{}).Count(&total)
db.Preload("Children").Order("id desc").Limit(perPage).Offset(offset).Find(&cats)
case "only":
db.Unscoped().Model(&models.Category{}).Where("deleted_at IS NOT NULL").Count(&total)
db.Unscoped().Preload("Children").Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&cats)
case "with":
db.Unscoped().Model(&models.Category{}).Count(&total)
db.Unscoped().Preload("Children").Order("id desc").Limit(perPage).Offset(offset).Find(&cats)
}
return c.JSON(fiber.Map{"data": cats, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}})
}
// HardDeleteCategory godoc
// @Summary Permanently delete a category (admin only)
// @Tags Categories
// @Produce json
// @Security BearerAuth
// @Param id path int true "Category ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/categories/{id}/hard [delete]
func HardDeleteCategory(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid category id"})
}
var cat models.Category
if err := database.DB.Unscoped().First(&cat, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
err = database.DB.Transaction(func(tx *gorm.DB) error {
// set parent_id of children to NULL to avoid FK issues
if err := tx.Model(&models.Category{}).Where("parent_id = ?", id).Update("parent_id", nil).Error; err != nil {
return err
}
// clear many2many association with posts to avoid FK constraint errors (post_categories)
if err := tx.Model(&cat).Association("Posts").Clear(); err != nil {
return err
}
return tx.Unscoped().Delete(&cat).Error
})
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category hard-delete failed"})
}
return c.JSON(fiber.Map{"message": "category permanently deleted", "category_id": id})
}
// AdminRestoreCategory godoc
// @Summary Restore soft-deleted category (admin only)
// @Tags Categories
// @Produce json
// @Security BearerAuth
// @Param id path int true "Category ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/categories/{id}/restore [post]
func AdminRestoreCategory(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid category id"})
}
var cat models.Category
if err := database.DB.Unscoped().First(&cat, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
if !cat.DeletedAt.Valid {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "category is not soft-deleted"})
}
if err := database.DB.Unscoped().Model(&models.Category{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category could not be restored"})
}
return c.JSON(fiber.Map{"message": "category restored successfully", "category_id": id})
}
// -------- TAGS --------
// ListTags godoc
// @Summary List tags (public)
// @Tags Tags
// @Produce json
// @Success 200 {array} models.TagDoc
// @Router /api/v1/tags [get]
func ListTags(c fiber.Ctx) error {
var tags []models.Tag
database.DB.Find(&tags)
return c.JSON(tags)
}
// CreateTagRequest represents payload for creating a tag
// swagger:model CreateTagRequest
type CreateTagRequest struct {
Name string `json:"name" validate:"required,min=1"`
}
// UpdateTagRequest represents payload for updating a tag
// swagger:model UpdateTagRequest
type UpdateTagRequest struct {
Name *string `json:"name" validate:"omitempty,min=1"`
}
// CreateTag godoc
// @Summary Create tag (admin only)
// @Tags Tags
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body CreateTagRequest true "Tag payload"
// @Success 201 {object} models.TagDoc
// @Failure 400 {object} map[string]string
// @Router /api/v1/tags [post]
func CreateTag(c fiber.Ctx) error {
var input CreateTagRequest
if err := c.Bind().Body(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := validate.Struct(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
tag := models.Tag{Name: input.Name}
if err := database.DB.Create(&tag).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create tag"})
}
return c.Status(http.StatusCreated).JSON(tag)
}
// UpdateTag godoc
// @Summary Update tag (admin only)
// @Tags Tags
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Tag ID"
// @Param data body UpdateTagRequest true "Tag payload"
// @Success 200 {object} models.TagDoc
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/tags/{id} [put]
func UpdateTag(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var tag models.Tag
if err := database.DB.First(&tag, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"})
}
var input UpdateTagRequest
if err := c.Bind().Body(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := validate.Struct(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
if input.Name != nil {
tag.Name = *input.Name
}
if err := database.DB.Save(&tag).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update tag"})
}
return c.JSON(tag)
}
// DeleteTag godoc
// @Summary Delete tag (admin only)
// @Tags Tags
// @Produce json
// @Security BearerAuth
// @Param id path int true "Tag ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/tags/{id} [delete]
func DeleteTag(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var tag models.Tag
if err := database.DB.First(&tag, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"})
}
if err := database.DB.Delete(&tag).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete tag"})
}
return c.JSON(fiber.Map{"message": "tag deleted"})
}
// AdminListTags godoc
// @Summary List tags (admin) with optional trashed filter
// @Tags Tags
// @Produce json
// @Security BearerAuth
// @Param trashed query string false "Trash filter: none|only|with"
// @Param page query int false "Page number"
// @Param per_page query int false "Items per page"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Router /api/v1/admin/tags [get]
func AdminListTags(c fiber.Ctx) error {
trashed := c.Query("trashed", "none")
if trashed != "none" && trashed != "only" && trashed != "with" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"})
}
pageStr := c.Query("page", "1")
perPageStr := c.Query("per_page", "10")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
var total int64
var tags []models.Tag
db := database.DB
switch trashed {
case "none":
db.Model(&models.Tag{}).Count(&total)
db.Preload("Posts").Order("id desc").Limit(perPage).Offset(offset).Find(&tags)
case "only":
db.Unscoped().Model(&models.Tag{}).Where("deleted_at IS NOT NULL").Count(&total)
db.Unscoped().Preload("Posts").Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&tags)
case "with":
db.Unscoped().Model(&models.Tag{}).Count(&total)
db.Unscoped().Preload("Posts").Order("id desc").Limit(perPage).Offset(offset).Find(&tags)
}
return c.JSON(fiber.Map{"data": tags, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}})
}
// HardDeleteTag godoc
// @Summary Permanently delete a tag (admin only)
// @Tags Tags
// @Produce json
// @Security BearerAuth
// @Param id path int true "Tag ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/tags/{id}/hard [delete]
func HardDeleteTag(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid tag id"})
}
var tag models.Tag
if err := database.DB.Unscoped().First(&tag, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
err = database.DB.Transaction(func(tx *gorm.DB) error {
// clear many2many association
if err := tx.Model(&tag).Association("Posts").Clear(); err != nil {
return err
}
return tx.Unscoped().Delete(&tag).Error
})
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tag hard-delete failed"})
}
return c.JSON(fiber.Map{"message": "tag permanently deleted", "tag_id": id})
}
// AdminRestoreTag godoc
// @Summary Restore soft-deleted tag (admin only)
// @Tags Tags
// @Produce json
// @Security BearerAuth
// @Param id path int true "Tag ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/tags/{id}/restore [post]
func AdminRestoreTag(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid tag id"})
}
var tag models.Tag
if err := database.DB.Unscoped().First(&tag, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
if !tag.DeletedAt.Valid {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "tag is not soft-deleted"})
}
if err := database.DB.Unscoped().Model(&models.Tag{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tag could not be restored"})
}
return c.JSON(fiber.Map{"message": "tag restored successfully", "tag_id": id})
}
// AdminListCategoryViews godoc
// @Summary List category views (admin) with optional trashed filter
// @Tags CategoryViews
// @Produce json
// @Security BearerAuth
// @Param trashed query string false "Trash filter: none|only|with"
// @Param page query int false "Page number"
// @Param per_page query int false "Items per page"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Router /api/v1/admin/category-views [get]
func AdminListCategoryViews(c fiber.Ctx) error {
trashed := c.Query("trashed", "none")
if trashed != "none" && trashed != "only" && trashed != "with" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"})
}
pageStr := c.Query("page", "1")
perPageStr := c.Query("per_page", "10")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
var total int64
var cvs []models.CategoryView
db := database.DB
switch trashed {
case "none":
db.Model(&models.CategoryView{}).Count(&total)
db.Order("id desc").Limit(perPage).Offset(offset).Find(&cvs)
case "only":
db.Unscoped().Model(&models.CategoryView{}).Where("deleted_at IS NOT NULL").Count(&total)
db.Unscoped().Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&cvs)
case "with":
db.Unscoped().Model(&models.CategoryView{}).Count(&total)
db.Unscoped().Order("id desc").Limit(perPage).Offset(offset).Find(&cvs)
}
return c.JSON(fiber.Map{"data": cvs, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}})
}
// HardDeleteCategoryView godoc
// @Summary Permanently delete a category view (admin only)
// @Tags CategoryViews
// @Produce json
// @Security BearerAuth
// @Param id path int true "CategoryView ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/category-views/{id}/hard [delete]
func HardDeleteCategoryView(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var cv models.CategoryView
if err := database.DB.Unscoped().First(&cv, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category view not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
if err := database.DB.Unscoped().Delete(&cv).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category view hard-delete failed"})
}
return c.JSON(fiber.Map{"message": "category view permanently deleted", "id": id})
}
// AdminRestoreCategoryView godoc
// @Summary Restore soft-deleted category view (admin only)
// @Tags CategoryViews
// @Produce json
// @Security BearerAuth
// @Param id path int true "CategoryView ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/category-views/{id}/restore [post]
func AdminRestoreCategoryView(c fiber.Ctx) error {
if database.DB == nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var cv models.CategoryView
if err := database.DB.Unscoped().First(&cv, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category view not found"})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
if !cv.DeletedAt.Valid {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "category view is not soft-deleted"})
}
if err := database.DB.Unscoped().Model(&models.CategoryView{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category view could not be restored"})
}
return c.JSON(fiber.Map{"message": "category view restored successfully", "id": id})
}
// -------- COMMENTS --------
// CreateComment godoc
// @Summary Create comment (public)
// @Tags Comments
// @Accept json
// @Produce json
// @Param data body object true "Comment payload"
// @Success 201 {object} models.CommentDoc
// @Failure 400 {object} map[string]string
// @Router /api/v1/comments [post]
func CreateComment(c fiber.Ctx) error {
var input struct {
UserID uint `json:"user_id" validate:"required"`
PostID uint `json:"post_id" validate:"required"`
Body string `json:"body" validate:"required,min=1"`
}
if err := c.Bind().Body(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := validate.Struct(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
cm := models.Comment{UserID: input.UserID, PostID: input.PostID, Body: input.Body}
if err := database.DB.Create(&cm).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create comment"})
}
return c.Status(http.StatusCreated).JSON(cm)
}
// ListComments godoc
// @Summary List comments for a post (public)
// @Tags Comments
// @Produce json
// @Param post_id query int true "Post ID"
// @Success 200 {array} models.CommentDoc
// @Router /api/v1/comments [get]
func ListComments(c fiber.Ctx) error {
postIDStr := c.Query("post_id")
if postIDStr == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "post_id required"})
}
postID, err := strconv.ParseUint(postIDStr, 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid post_id"})
}
var comments []models.Comment
database.DB.Where("post_id = ?", postID).Find(&comments)
return c.JSON(comments)
}
// DeleteComment godoc
// @Summary Delete comment (admin only)
// @Tags Comments
// @Produce json
// @Security BearerAuth
// @Param id path int true "Comment ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/comments/{id} [delete]
func DeleteComment(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
var cm models.Comment
if err := database.DB.First(&cm, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "comment not found"})
}
if err := database.DB.Delete(&cm).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete comment"})
}
return c.JSON(fiber.Map{"message": "comment deleted"})
}