Files
ares/controllers/blog_controller.go
Beyhan Oğur 4d92991817 first commit
2026-04-26 21:30:42 +03:00

1124 lines
36 KiB
Go
Raw Permalink 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 (
database "ares/database/config"
"ares/database/models"
"encoding/json"
"errors"
"fmt"
"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 slug path string true "Post slug"
// @Success 200 {object} models.PostDoc
// @Failure 404 {object} map[string]string
// @Router /api/v1/posts/{slug} [get]
func GetPost(c fiber.Ctx) error {
slug := c.Params("slug")
if slug == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid slug"})
}
var post models.Post
if err := database.DB.Preload("Categories").Preload("Tags").Where("slug = ? AND deleted_at IS NULL", slug).First(&post).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": "db error"})
}
return c.JSON(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 ""
}
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)
}
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"})
}
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}})
}
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,
})
}
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 slug path string true "Category slug"
// @Success 200 {object} models.CategoryDoc
// @Failure 404 {object} map[string]string
// @Router /api/v1/categories/{slug} [get]
func GetCategory(c fiber.Ctx) error {
slug := c.Params("slug")
if slug == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid slug"})
}
var cat models.Category
if err := database.DB.Preload("Children").Where("slug = ?", slug).First(&cat).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": "db error"})
}
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"`
}
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)
}
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)
}
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"})
}
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}})
}
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})
}
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"`
}
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)
}
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)
}
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"})
}
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}})
}
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})
}
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})
}
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}})
}
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})
}
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)
}
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"})
}