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