Yimaru-BackEnd/internal/web_server/handlers/faq.go
Yared Yemane 6a4fe68628 Add full FAQ management APIs and integration assets.
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>
2026-05-11 07:58:17 -07:00

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},
})
}