Yimaru-BackEnd/internal/web_server/handlers/field_option.go
Yared Yemane a5acd00637 Add admin-managed field options API for configurable dropdowns.
Store options in field_options with public /field-options and admin CRUD; validate learner profile values on update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:21:36 -07:00

304 lines
9.3 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type createFieldOptionReq struct {
FieldKey string `json:"field_key" validate:"required"`
Code string `json:"code" validate:"required"`
Label string `json:"label" validate:"required"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type updateFieldOptionReq struct {
Label *string `json:"label"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type fieldOptionRes struct {
ID int64 `json:"id"`
FieldKey string `json:"field_key"`
Code string `json:"code"`
Label string `json:"label"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at,omitempty"`
}
type listFieldOptionsRes struct {
Options []fieldOptionRes `json:"options"`
TotalCount int64 `json:"total_count"`
}
func mapFieldOptionToRes(o domain.ProfileFieldOption) fieldOptionRes {
var updatedAt *string
if o.UpdatedAt != nil {
v := o.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
updatedAt = &v
}
return fieldOptionRes{
ID: o.ID,
FieldKey: o.FieldKey,
Code: o.Code,
Label: o.Label,
DisplayOrder: o.DisplayOrder,
Status: o.Status,
CreatedAt: o.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: updatedAt,
}
}
// ListPublicFieldOptions godoc
// @Summary List field options for client dropdowns
// @Description Returns active options grouped by field_key (e.g. education_level, country)
// @Tags field-options
// @Produce json
// @Param field_key query string false "Filter by field key"
// @Success 200 {object} domain.Response
// @Router /api/v1/field-options [get]
func (h *Handler) ListPublicFieldOptions(c *fiber.Ctx) error {
var fieldKeyPtr *string
if raw := strings.TrimSpace(c.Query("field_key")); raw != "" {
fieldKeyPtr = &raw
}
grouped, err := h.profileFieldOptionSvc.ListActiveOptionsGrouped(c.Context(), fieldKeyPtr)
if err != nil {
return fieldOptionError(c, err, "Failed to list field options")
}
return c.JSON(domain.Response{
Message: "Field options retrieved successfully",
Data: grouped,
Success: true,
})
}
// ListFieldKeys godoc
// @Summary List distinct field keys
// @Description Returns field_key values that have options (e.g. education_level, country)
// @Tags field-options
// @Produce json
// @Param active_only query bool false "If true, only keys with active options"
// @Success 200 {object} domain.Response
// @Router /api/v1/field-options/fields [get]
func (h *Handler) ListFieldKeys(c *fiber.Ctx) error {
activeOnly := c.Query("active_only", "true") == "true"
keys, err := h.profileFieldOptionSvc.ListFieldKeys(c.Context(), activeOnly)
if err != nil {
return fieldOptionError(c, err, "Failed to list field keys")
}
return c.JSON(domain.Response{
Message: "Field keys retrieved successfully",
Data: fiber.Map{
"fields": keys,
},
Success: true,
})
}
// ListFieldOptionsAdmin godoc
// @Summary List field options (admin)
// @Tags field-options
// @Produce json
// @Param field_key query string false "Filter by field key"
// @Param status query string false "ACTIVE or INACTIVE"
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options [get]
func (h *Handler) ListFieldOptionsAdmin(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "50"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
var fieldKeyPtr, statusPtr *string
if raw := strings.TrimSpace(c.Query("field_key")); raw != "" {
fieldKeyPtr = &raw
}
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
statusPtr = &raw
}
options, total, err := h.profileFieldOptionSvc.ListProfileFieldOptions(c.Context(), fieldKeyPtr, statusPtr, int32(limit), int32(offset))
if err != nil {
return fieldOptionError(c, err, "Failed to list field options")
}
out := make([]fieldOptionRes, 0, len(options))
for _, o := range options {
out = append(out, mapFieldOptionToRes(o))
}
return c.JSON(domain.Response{
Message: "Field options retrieved successfully",
Data: listFieldOptionsRes{
Options: out,
TotalCount: total,
},
Success: true,
})
}
// GetFieldOptionByIDAdmin godoc
// @Summary Get field option by ID (admin)
// @Tags field-options
// @Produce json
// @Param id path int true "Option ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options/{id} [get]
func (h *Handler) GetFieldOptionByIDAdmin(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 option ID",
})
}
option, err := h.profileFieldOptionSvc.GetProfileFieldOptionByID(c.Context(), id, true)
if err != nil {
return fieldOptionError(c, err, "Failed to get field option")
}
return c.JSON(domain.Response{
Message: "Field option retrieved successfully",
Data: mapFieldOptionToRes(option),
Success: true,
})
}
// CreateFieldOption godoc
// @Summary Create field option (admin)
// @Tags field-options
// @Accept json
// @Produce json
// @Param body body createFieldOptionReq true "Create option"
// @Success 201 {object} domain.Response
// @Router /api/v1/admin/field-options [post]
func (h *Handler) CreateFieldOption(c *fiber.Ctx) error {
var req createFieldOptionReq
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),
})
}
option, err := h.profileFieldOptionSvc.CreateProfileFieldOption(c.Context(), domain.CreateProfileFieldOptionInput{
FieldKey: req.FieldKey,
Code: req.Code,
Label: req.Label,
DisplayOrder: req.DisplayOrder,
Status: req.Status,
})
if err != nil {
return fieldOptionError(c, err, "Failed to create field option")
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Field option created successfully",
Data: mapFieldOptionToRes(option),
Success: true,
})
}
// UpdateFieldOption godoc
// @Summary Update field option (admin)
// @Tags field-options
// @Accept json
// @Produce json
// @Param id path int true "Option ID"
// @Param body body updateFieldOptionReq true "Update option"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options/{id} [put]
func (h *Handler) UpdateFieldOption(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 option ID",
})
}
var req updateFieldOptionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
option, err := h.profileFieldOptionSvc.UpdateProfileFieldOption(c.Context(), id, domain.UpdateProfileFieldOptionInput{
Label: req.Label,
DisplayOrder: req.DisplayOrder,
Status: req.Status,
})
if err != nil {
return fieldOptionError(c, err, "Failed to update field option")
}
return c.JSON(domain.Response{
Message: "Field option updated successfully",
Data: mapFieldOptionToRes(option),
Success: true,
})
}
// DeleteFieldOption godoc
// @Summary Delete field option (admin)
// @Tags field-options
// @Produce json
// @Param id path int true "Option ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options/{id} [delete]
func (h *Handler) DeleteFieldOption(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 option ID",
})
}
if err := h.profileFieldOptionSvc.DeleteProfileFieldOption(c.Context(), id); err != nil {
return fieldOptionError(c, err, "Failed to delete field option")
}
return c.JSON(domain.Response{
Message: "Field option deleted successfully",
Success: true,
})
}
func fieldOptionError(c *fiber.Ctx, err error, message string) error {
switch {
case errors.Is(err, domain.ErrInvalidFieldKey),
errors.Is(err, domain.ErrInvalidOptionCode):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
case errors.Is(err, pgx.ErrNoRows):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Field option not found"})
default:
if strings.Contains(err.Error(), "invalid value for") {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
}
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "An option with this field_key and code already exists"})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
}
}