Merge pull request 'create CMS error handling' (#25) from TM-578 into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/25
Reviewed-by: Hadi Barzegar <barzagarhadi@gmail.com>
This commit is contained in:
m.nazemi
2026-05-31 18:27:23 +03:30
6 changed files with 199 additions and 33 deletions
+1
View File
@@ -204,6 +204,7 @@ func main() {
userValidator := user.NewValidationService() userValidator := user.NewValidationService()
customerValidator := customer.NewValidationService() customerValidator := customer.NewValidationService()
_ = kanban.NewValidationService() // Register hexcolor validator _ = kanban.NewValidationService() // Register hexcolor validator
_ = cms.NewValidationService() // Register mediaRef validator
// Initialize services with repositories // Initialize services with repositories
auditLogger := audit.NewLogger(logger) auditLogger := audit.NewLogger(logger)
+4 -4
View File
@@ -7,7 +7,7 @@ type CMSForm struct {
Key string `json:"key" valid:"required,length(2|100)" example:"SW-001"` Key string `json:"key" valid:"required,length(2|100)" example:"SW-001"`
Language string `json:"language" valid:"optional,length(2|100)" example:"en"` Language string `json:"language" valid:"optional,length(2|100)" example:"en"`
CompanyName string `json:"company_name" valid:"optional,length(2|100)" example:"Opplens"` CompanyName string `json:"company_name" valid:"optional,length(2|100)" example:"Opplens"`
Country string `json:"country" valid:"optional,length(3|3)" example:"SWE"` Country string `json:"country" valid:"optional,length(2|3)" example:"SWE"`
Type CMSType `json:"type" valid:"optional,in(company|organization)" example:"company"` Type CMSType `json:"type" valid:"optional,in(company|organization)" example:"company"`
Hero heroSectionForm `json:"hero" valid:"optional"` Hero heroSectionForm `json:"hero" valid:"optional"`
Chart chartSectionForm `json:"chart" valid:"optional"` Chart chartSectionForm `json:"chart" valid:"optional"`
@@ -24,7 +24,7 @@ type heroSectionForm struct {
Description string `json:"description" valid:"optional,length(2|1000)" example:"Hero Description"` Description string `json:"description" valid:"optional,length(2|1000)" example:"Hero Description"`
ButtonText string `json:"button_text" valid:"optional,length(2|100)" example:"Button Text"` ButtonText string `json:"button_text" valid:"optional,length(2|100)" example:"Button Text"`
ButtonLink string `json:"button_link" valid:"optional,url" example:"https://example.com"` ButtonLink string `json:"button_link" valid:"optional,url" example:"https://example.com"`
GifFile string `json:"gif_file" valid:"optional,url" example:"https://example.com/gif.gif"` GifFile string `json:"gif_file" valid:"optional,mediaRef" example:"674abc123def456789012345"`
} }
// chartSectionForm represents the form for creating/updating the chart section // chartSectionForm represents the form for creating/updating the chart section
@@ -49,7 +49,7 @@ type cardSectionForm struct {
// cardForm represents the form for creating/updating a card // cardForm represents the form for creating/updating a card
type cardForm struct { type cardForm struct {
Icon string `json:"icon" valid:"optional,url" example:"https://example.com/icon.png"` Icon string `json:"icon" valid:"optional,mediaRef" example:"674abc123def456789012345"`
Title string `json:"title" valid:"optional,length(2|100)" example:"Card Title"` Title string `json:"title" valid:"optional,length(2|100)" example:"Card Title"`
Description string `json:"description" valid:"optional,length(2|1000)" example:"Card Description"` Description string `json:"description" valid:"optional,length(2|1000)" example:"Card Description"`
} }
@@ -73,7 +73,7 @@ type formFieldForm struct {
// footerSectionForm represents the form for creating/updating the footer section // footerSectionForm represents the form for creating/updating the footer section
type footerSectionForm struct { type footerSectionForm struct {
Email string `json:"email" valid:"optional,email" example:"info@example.com"` Email string `json:"email" valid:"optional,email" example:"info@example.com"`
Phone string `json:"phone" valid:"optional,length(10|20)" example:"+1234567890"` Phone string `json:"phone" valid:"optional,length(0|30)" example:"+1234567890"`
Location string `json:"location" valid:"optional,length(2|100)" example:"Location"` Location string `json:"location" valid:"optional,length(2|100)" example:"Location"`
Tagline string `json:"tagline" valid:"optional,length(2|100)" example:"Tagline"` Tagline string `json:"tagline" valid:"optional,length(2|100)" example:"Tagline"`
Copyright string `json:"copyright" valid:"optional,length(2|100)" example:"Copyright"` Copyright string `json:"copyright" valid:"optional,length(2|100)" example:"Copyright"`
+12 -29
View File
@@ -53,21 +53,17 @@ func (h *Handler) GetByKey(c echo.Context) error {
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /admin/v1/cms [post] // @Router /admin/v1/cms [post]
func (h *Handler) Create(c echo.Context) error { func (h *Handler) Create(c echo.Context) error {
form, err := response.Parse[CMSForm](c) form, err := ParseForm(c)
if err != nil { if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error()) return handleParseError(c, err)
} }
// Create CMS
cms, err := h.service.Create(c.Request().Context(), form) cms, err := h.service.Create(c.Request().Context(), form)
if err != nil { if err != nil {
if err == ErrCMSKeyExists { return handleServiceError(c, err, "Failed to create marketing page")
return response.Conflict(c, "CMS with this key already exists")
}
return response.InternalServerError(c, "Failed to create CMS")
} }
return response.Created(c, cms, "CMS entry created successfully") return response.Created(c, cms, "Marketing page created successfully")
} }
// GetByID handles retrieving a CMS entry by ID (admin only) // GetByID handles retrieving a CMS entry by ID (admin only)
@@ -86,13 +82,10 @@ func (h *Handler) GetByID(c echo.Context) error {
cms, err := h.service.GetByID(c.Request().Context(), id) cms, err := h.service.GetByID(c.Request().Context(), id)
if err != nil { if err != nil {
if err.Error() == "cms not found" { return handleServiceError(c, err, "Failed to get marketing page")
return response.NotFound(c, "CMS entry not found")
}
return response.InternalServerError(c, "Failed to get CMS entry")
} }
return response.Success(c, cms, "CMS entry retrieved successfully") return response.Success(c, cms, "Marketing page retrieved successfully")
} }
// Update handles updating a CMS entry (admin only) // Update handles updating a CMS entry (admin only)
@@ -112,24 +105,17 @@ func (h *Handler) GetByID(c echo.Context) error {
func (h *Handler) Update(c echo.Context) error { func (h *Handler) Update(c echo.Context) error {
id := c.Param("id") id := c.Param("id")
form, err := response.Parse[CMSForm](c) form, err := ParseForm(c)
if err != nil { if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error()) return handleParseError(c, err)
} }
// Update CMS
cms, err := h.service.Update(c.Request().Context(), id, form) cms, err := h.service.Update(c.Request().Context(), id, form)
if err != nil { if err != nil {
if err == ErrCMSKeyExists { return handleServiceError(c, err, "Failed to update marketing page")
return response.Conflict(c, "CMS with this key already exists")
}
if err.Error() == "cms not found" {
return response.NotFound(c, "CMS entry not found")
}
return response.InternalServerError(c, "Failed to update CMS entry")
} }
return response.Success(c, cms, "CMS entry updated successfully") return response.Success(c, cms, "Marketing page updated successfully")
} }
// Delete handles deleting a CMS entry (admin only) // Delete handles deleting a CMS entry (admin only)
@@ -148,13 +134,10 @@ func (h *Handler) Delete(c echo.Context) error {
err := h.service.Delete(c.Request().Context(), id) err := h.service.Delete(c.Request().Context(), id)
if err != nil { if err != nil {
if err.Error() == "cms not found" { return handleServiceError(c, err, "Failed to delete marketing page")
return response.NotFound(c, "CMS entry not found")
}
return response.InternalServerError(c, "Failed to delete CMS entry")
} }
return response.Success(c, nil, "CMS entry deleted successfully") return response.Success(c, nil, "Marketing page deleted successfully")
} }
// Search handles searching CMS entries (admin only) // Search handles searching CMS entries (admin only)
+33
View File
@@ -0,0 +1,33 @@
package cms
import (
"errors"
"strings"
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
func handleParseError(c echo.Context, err error) error {
if err == nil {
return nil
}
if strings.Contains(err.Error(), "invalid request format") {
return response.BadRequest(c, "Invalid request format", err.Error())
}
return response.ValidationError(c, "Please fix the validation errors", FormatValidationErrors(err))
}
func handleServiceError(c echo.Context, err error, fallback string) error {
if err == nil {
return nil
}
if errors.Is(err, ErrCMSKeyExists) {
return response.Conflict(c, "A marketing page with this key already exists")
}
if errors.Is(err, ErrCMSNotFound) {
return response.NotFound(c, "Marketing page not found")
}
return response.InternalServerError(c, fallback)
}
+13
View File
@@ -2,6 +2,7 @@ package cms
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/response" "tm/pkg/response"
@@ -90,6 +91,9 @@ func (s *cmsService) GetByID(ctx context.Context, id string) (*CMSResponse, erro
cms, err := s.repo.GetByID(ctx, id) cms, err := s.repo.GetByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, ErrCMSNotFound) {
return nil, ErrCMSNotFound
}
s.logger.Error("Failed to get CMS by ID", map[string]interface{}{ s.logger.Error("Failed to get CMS by ID", map[string]interface{}{
"id": id, "id": id,
"error": err.Error(), "error": err.Error(),
@@ -108,6 +112,9 @@ func (s *cmsService) GetByKey(ctx context.Context, key string) (*CMSResponse, er
cms, err := s.repo.GetByKey(ctx, key) cms, err := s.repo.GetByKey(ctx, key)
if err != nil { if err != nil {
if errors.Is(err, ErrCMSNotFound) {
return nil, ErrCMSNotFound
}
s.logger.Error("Failed to get CMS by key", map[string]interface{}{ s.logger.Error("Failed to get CMS by key", map[string]interface{}{
"key": key, "key": key,
"error": err.Error(), "error": err.Error(),
@@ -128,6 +135,9 @@ func (s *cmsService) Update(ctx context.Context, id string, form *CMSForm) (*CMS
// Get existing CMS // Get existing CMS
cms, err := s.repo.GetByID(ctx, id) cms, err := s.repo.GetByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, ErrCMSNotFound) {
return nil, ErrCMSNotFound
}
s.logger.Error("Failed to get existing CMS for update", map[string]interface{}{ s.logger.Error("Failed to get existing CMS for update", map[string]interface{}{
"id": id, "id": id,
"error": err.Error(), "error": err.Error(),
@@ -175,6 +185,9 @@ func (s *cmsService) Delete(ctx context.Context, id string) error {
}) })
if err := s.repo.Delete(ctx, id); err != nil { if err := s.repo.Delete(ctx, id); err != nil {
if errors.Is(err, ErrCMSNotFound) {
return ErrCMSNotFound
}
s.logger.Error("Failed to delete CMS", map[string]interface{}{ s.logger.Error("Failed to delete CMS", map[string]interface{}{
"id": id, "id": id,
"error": err.Error(), "error": err.Error(),
+136
View File
@@ -0,0 +1,136 @@
package cms
import (
"fmt"
"regexp"
"strings"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo/v4"
)
var (
objectIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{24}$`)
filePathPattern = regexp.MustCompile(`^/[^/]*/v\d+/files/[a-fA-F0-9]{24}(/download)?$`)
)
// ValidationService registers CMS-specific govalidator rules.
type ValidationService struct{}
// NewValidationService registers custom validators used by CMS forms.
func NewValidationService() ValidationService {
govalidator.CustomTypeTagMap.Set("mediaRef", govalidator.CustomTypeValidator(func(i interface{}, _ interface{}) bool {
value, ok := i.(string)
if !ok {
return false
}
return isMediaReference(value)
}))
return ValidationService{}
}
func isMediaReference(value string) bool {
if value == "" {
return true
}
if govalidator.IsURL(value) {
return true
}
if objectIDPattern.MatchString(value) {
return true
}
return filePathPattern.MatchString(value)
}
// ValidateCMSForm validates a CMS create/update payload.
func ValidateCMSForm(form *CMSForm) error {
_, err := govalidator.ValidateStruct(form)
return err
}
// ParseForm binds and validates a CMS form from the request.
func ParseForm(c echo.Context) (*CMSForm, error) {
var form CMSForm
if err := c.Bind(&form); err != nil {
return nil, fmt.Errorf("invalid request format: %w", err)
}
if err := ValidateCMSForm(&form); err != nil {
return nil, err
}
return &form, nil
}
// FormatValidationErrors converts govalidator errors into user-facing messages.
func FormatValidationErrors(err error) string {
if err == nil {
return ""
}
errs, ok := err.(govalidator.Errors)
if !ok {
return humanizeValidationError(err.Error())
}
messages := make([]string, 0, len(errs))
for _, item := range errs {
messages = append(messages, humanizeValidationError(item.Error()))
}
return strings.Join(messages, "; ")
}
func humanizeValidationError(raw string) string {
field, rule, ok := parseGovalidatorError(raw)
if !ok {
return raw
}
label := fieldLabel(field)
lowerRule := strings.ToLower(rule)
switch {
case strings.Contains(lowerRule, "mediaref"):
return label + " must be a valid URL or uploaded file"
case strings.Contains(lowerRule, "url"):
return label + " must be a valid URL"
case strings.Contains(lowerRule, "email"):
return label + " must be a valid email address"
case strings.Contains(lowerRule, "length"):
return label + " has an invalid length"
case strings.Contains(lowerRule, "range"):
return label + " is out of the allowed range"
case strings.Contains(lowerRule, "in("):
return label + " has an invalid value"
default:
return label + " is invalid"
}
}
func parseGovalidatorError(raw string) (field, rule string, ok bool) {
parts := strings.SplitN(raw, ": ", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
func fieldLabel(field string) string {
field = strings.TrimPrefix(field, "CMSForm.")
replacer := strings.NewReplacer(
"Hero.", "Hero section: ",
"Chart.", "Chart section: ",
"Features.", "Features section: ",
"Challenges.", "Challenges section: ",
"Advantages.", "Advantages section: ",
"Contact.", "Contact section: ",
"Footer.", "Footer section: ",
".", " ",
)
label := replacer.Replace(field)
label = strings.ReplaceAll(label, "Cards 0", "card 1")
label = strings.ReplaceAll(label, "Cards 1", "card 2")
label = strings.ReplaceAll(label, "Cards 2", "card 3")
label = strings.ReplaceAll(label, "Cards 3", "card 4")
label = strings.ReplaceAll(label, "Cards 4", "card 5")
return label
}