1365 lines
45 KiB
Go
1365 lines
45 KiB
Go
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"})
|
||
}
|