Implement public FAQ read endpoints and admin CRUD with RBAC, persistence, and migrations, then regenerate Swagger and add a complete Postman collection so frontend/admin teams can integrate and validate the feature end-to-end. Co-authored-by: Cursor <cursoragent@cursor.com>
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"Yimaru-Backend/internal/domain"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type createFAQReq struct {
|
|
Question string `json:"question" validate:"required"`
|
|
Answer string `json:"answer" validate:"required"`
|
|
Category *string `json:"category"`
|
|
DisplayOrder *int32 `json:"display_order"`
|
|
Status *string `json:"status"`
|
|
}
|
|
|
|
type updateFAQReq struct {
|
|
Question *string `json:"question"`
|
|
Answer *string `json:"answer"`
|
|
Category *string `json:"category"`
|
|
DisplayOrder *int32 `json:"display_order"`
|
|
Status *string `json:"status"`
|
|
}
|
|
|
|
type faqRes struct {
|
|
ID int64 `json:"id"`
|
|
Question string `json:"question"`
|
|
Answer string `json:"answer"`
|
|
Category *string `json:"category,omitempty"`
|
|
DisplayOrder int32 `json:"display_order"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt *string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
type listFAQsRes struct {
|
|
FAQs []faqRes `json:"faqs"`
|
|
TotalCount int64 `json:"total_count"`
|
|
}
|
|
|
|
func mapFAQToRes(f domain.FAQ) faqRes {
|
|
var updatedAt *string
|
|
if f.UpdatedAt != nil {
|
|
value := f.UpdatedAt.String()
|
|
updatedAt = &value
|
|
}
|
|
return faqRes{
|
|
ID: f.ID,
|
|
Question: f.Question,
|
|
Answer: f.Answer,
|
|
Category: f.Category,
|
|
DisplayOrder: f.DisplayOrder,
|
|
Status: f.Status,
|
|
CreatedAt: f.CreatedAt.String(),
|
|
UpdatedAt: updatedAt,
|
|
}
|
|
}
|
|
|
|
// ListPublicFAQs godoc
|
|
// @Summary List published FAQs
|
|
// @Description Returns active FAQs for public/help center usage
|
|
// @Tags faqs
|
|
// @Produce json
|
|
// @Param category query string false "Filter by category"
|
|
// @Param limit query int false "Limit (default 50)"
|
|
// @Param offset query int false "Offset (default 0)"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/faqs [get]
|
|
func (h *Handler) ListPublicFAQs(c *fiber.Ctx) error {
|
|
category := strings.TrimSpace(c.Query("category"))
|
|
var categoryPtr *string
|
|
if category != "" {
|
|
categoryPtr = &category
|
|
}
|
|
|
|
limit, err := strconv.Atoi(c.Query("limit", "50"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid limit",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
offset, err := strconv.Atoi(c.Query("offset", "0"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid offset",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
status := domain.FAQStatusActive
|
|
faqs, total, err := h.faqSvc.ListFAQs(c.Context(), &status, categoryPtr, int32(limit), int32(offset))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to list FAQs",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
out := make([]faqRes, 0, len(faqs))
|
|
for _, f := range faqs {
|
|
out = append(out, mapFAQToRes(f))
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "FAQs retrieved successfully",
|
|
Data: listFAQsRes{
|
|
FAQs: out,
|
|
TotalCount: total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetPublicFAQByID godoc
|
|
// @Summary Get published FAQ by ID
|
|
// @Description Returns one active FAQ item
|
|
// @Tags faqs
|
|
// @Produce json
|
|
// @Param id path int true "FAQ ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 404 {object} domain.ErrorResponse
|
|
// @Router /api/v1/faqs/{id} [get]
|
|
func (h *Handler) GetPublicFAQByID(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid FAQ ID",
|
|
Error: "id must be a positive integer",
|
|
})
|
|
}
|
|
|
|
faq, err := h.faqSvc.GetFAQByID(c.Context(), id, false)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
Message: "FAQ not found",
|
|
})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to get FAQ",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "FAQ retrieved successfully",
|
|
Data: mapFAQToRes(faq),
|
|
})
|
|
}
|
|
|
|
// ListFAQsAdmin godoc
|
|
// @Summary List FAQs (admin)
|
|
// @Description Returns FAQs for admin management with status/category filtering
|
|
// @Tags faqs
|
|
// @Produce json
|
|
// @Param status query string false "ACTIVE or INACTIVE"
|
|
// @Param category query string false "Filter by category"
|
|
// @Param limit query int false "Limit (default 20)"
|
|
// @Param offset query int false "Offset (default 0)"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/admin/faqs [get]
|
|
func (h *Handler) ListFAQsAdmin(c *fiber.Ctx) error {
|
|
status := strings.ToUpper(strings.TrimSpace(c.Query("status")))
|
|
var statusPtr *string
|
|
if status != "" {
|
|
statusPtr = &status
|
|
}
|
|
category := strings.TrimSpace(c.Query("category"))
|
|
var categoryPtr *string
|
|
if category != "" {
|
|
categoryPtr = &category
|
|
}
|
|
|
|
limit, err := strconv.Atoi(c.Query("limit", "20"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid limit",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
offset, err := strconv.Atoi(c.Query("offset", "0"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid offset",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
faqs, total, err := h.faqSvc.ListFAQs(c.Context(), statusPtr, categoryPtr, int32(limit), int32(offset))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Failed to list FAQs",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
out := make([]faqRes, 0, len(faqs))
|
|
for _, f := range faqs {
|
|
out = append(out, mapFAQToRes(f))
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "FAQs retrieved successfully",
|
|
Data: listFAQsRes{
|
|
FAQs: out,
|
|
TotalCount: total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetFAQByIDAdmin godoc
|
|
// @Summary Get FAQ by ID (admin)
|
|
// @Description Returns one FAQ regardless of status
|
|
// @Tags faqs
|
|
// @Produce json
|
|
// @Param id path int true "FAQ ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 404 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/admin/faqs/{id} [get]
|
|
func (h *Handler) GetFAQByIDAdmin(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid FAQ ID",
|
|
Error: "id must be a positive integer",
|
|
})
|
|
}
|
|
|
|
faq, err := h.faqSvc.GetFAQByID(c.Context(), id, true)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
Message: "FAQ not found",
|
|
})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to get FAQ",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "FAQ retrieved successfully",
|
|
Data: mapFAQToRes(faq),
|
|
})
|
|
}
|
|
|
|
// CreateFAQ godoc
|
|
// @Summary Create FAQ
|
|
// @Description Creates a new FAQ item
|
|
// @Tags faqs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body createFAQReq true "Create FAQ payload"
|
|
// @Success 201 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/admin/faqs [post]
|
|
func (h *Handler) CreateFAQ(c *fiber.Ctx) error {
|
|
var req createFAQReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid request body",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Validation failed",
|
|
Error: firstValidationError(valErrs),
|
|
})
|
|
}
|
|
|
|
faq, err := h.faqSvc.CreateFAQ(c.Context(), domain.CreateFAQInput{
|
|
Question: req.Question,
|
|
Answer: req.Answer,
|
|
Category: req.Category,
|
|
DisplayOrder: req.DisplayOrder,
|
|
Status: req.Status,
|
|
})
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Failed to create FAQ",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
|
Message: "FAQ created successfully",
|
|
Data: mapFAQToRes(faq),
|
|
})
|
|
}
|
|
|
|
// UpdateFAQ godoc
|
|
// @Summary Update FAQ
|
|
// @Description Updates an existing FAQ item
|
|
// @Tags faqs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path int true "FAQ ID"
|
|
// @Param body body updateFAQReq true "Update FAQ payload"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 404 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/admin/faqs/{id} [put]
|
|
func (h *Handler) UpdateFAQ(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid FAQ ID",
|
|
Error: "id must be a positive integer",
|
|
})
|
|
}
|
|
|
|
var req updateFAQReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid request body",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
faq, err := h.faqSvc.UpdateFAQ(c.Context(), id, domain.UpdateFAQInput{
|
|
Question: req.Question,
|
|
Answer: req.Answer,
|
|
Category: req.Category,
|
|
DisplayOrder: req.DisplayOrder,
|
|
Status: req.Status,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
Message: "FAQ not found",
|
|
})
|
|
}
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Failed to update FAQ",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "FAQ updated successfully",
|
|
Data: mapFAQToRes(faq),
|
|
})
|
|
}
|
|
|
|
// DeleteFAQ godoc
|
|
// @Summary Delete FAQ
|
|
// @Description Deletes an FAQ item
|
|
// @Tags faqs
|
|
// @Produce json
|
|
// @Param id path int true "FAQ ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 404 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/admin/faqs/{id} [delete]
|
|
func (h *Handler) DeleteFAQ(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid FAQ ID",
|
|
Error: "id must be a positive integer",
|
|
})
|
|
}
|
|
|
|
if err := h.faqSvc.DeleteFAQ(c.Context(), id); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
Message: "FAQ not found",
|
|
})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to delete FAQ",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "FAQ deleted successfully",
|
|
Data: fiber.Map{"id": id},
|
|
})
|
|
}
|