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