From 31627a1bbe3add89fcce99d7d546e5503d89115f Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 31 May 2026 02:17:46 +0330 Subject: [PATCH] create CMS error handling --- cmd/web/main.go | 1 + internal/cms/form.go | 8 +- internal/cms/handler.go | 41 +++------- internal/cms/handler_errors.go | 33 ++++++++ internal/cms/service.go | 13 ++++ internal/cms/validation.go | 136 +++++++++++++++++++++++++++++++++ 6 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 internal/cms/handler_errors.go create mode 100644 internal/cms/validation.go diff --git a/cmd/web/main.go b/cmd/web/main.go index 48fc7ed..32f72ba 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -204,6 +204,7 @@ func main() { userValidator := user.NewValidationService() customerValidator := customer.NewValidationService() _ = kanban.NewValidationService() // Register hexcolor validator + _ = cms.NewValidationService() // Register mediaRef validator // Initialize services with repositories auditLogger := audit.NewLogger(logger) diff --git a/internal/cms/form.go b/internal/cms/form.go index aec4d7b..9d2c7df 100644 --- a/internal/cms/form.go +++ b/internal/cms/form.go @@ -7,7 +7,7 @@ type CMSForm struct { Key string `json:"key" valid:"required,length(2|100)" example:"SW-001"` Language string `json:"language" valid:"optional,length(2|100)" example:"en"` 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"` Hero heroSectionForm `json:"hero" 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"` 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"` - 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 @@ -49,7 +49,7 @@ type cardSectionForm struct { // cardForm represents the form for creating/updating a card 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"` 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 type footerSectionForm struct { 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"` Tagline string `json:"tagline" valid:"optional,length(2|100)" example:"Tagline"` Copyright string `json:"copyright" valid:"optional,length(2|100)" example:"Copyright"` diff --git a/internal/cms/handler.go b/internal/cms/handler.go index 37b9370..6a1e23a 100644 --- a/internal/cms/handler.go +++ b/internal/cms/handler.go @@ -53,21 +53,17 @@ func (h *Handler) GetByKey(c echo.Context) error { // @Failure 500 {object} response.APIResponse // @Router /admin/v1/cms [post] func (h *Handler) Create(c echo.Context) error { - form, err := response.Parse[CMSForm](c) + form, err := ParseForm(c) 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) if err != nil { - if err == ErrCMSKeyExists { - return response.Conflict(c, "CMS with this key already exists") - } - return response.InternalServerError(c, "Failed to create CMS") + return handleServiceError(c, err, "Failed to create marketing page") } - 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) @@ -86,13 +82,10 @@ func (h *Handler) GetByID(c echo.Context) error { cms, err := h.service.GetByID(c.Request().Context(), id) if err != nil { - if err.Error() == "cms not found" { - return response.NotFound(c, "CMS entry not found") - } - return response.InternalServerError(c, "Failed to get CMS entry") + return handleServiceError(c, err, "Failed to get marketing page") } - 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) @@ -112,24 +105,17 @@ func (h *Handler) GetByID(c echo.Context) error { func (h *Handler) Update(c echo.Context) error { id := c.Param("id") - form, err := response.Parse[CMSForm](c) + form, err := ParseForm(c) 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) if err != nil { - if err == ErrCMSKeyExists { - 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 handleServiceError(c, err, "Failed to update marketing page") } - 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) @@ -148,13 +134,10 @@ func (h *Handler) Delete(c echo.Context) error { err := h.service.Delete(c.Request().Context(), id) if err != nil { - if err.Error() == "cms not found" { - return response.NotFound(c, "CMS entry not found") - } - return response.InternalServerError(c, "Failed to delete CMS entry") + return handleServiceError(c, err, "Failed to delete marketing page") } - 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) diff --git a/internal/cms/handler_errors.go b/internal/cms/handler_errors.go new file mode 100644 index 0000000..ab25140 --- /dev/null +++ b/internal/cms/handler_errors.go @@ -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) +} diff --git a/internal/cms/service.go b/internal/cms/service.go index 6302922..dabe003 100644 --- a/internal/cms/service.go +++ b/internal/cms/service.go @@ -2,6 +2,7 @@ package cms import ( "context" + "errors" "fmt" "tm/pkg/logger" "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) if err != nil { + if errors.Is(err, ErrCMSNotFound) { + return nil, ErrCMSNotFound + } s.logger.Error("Failed to get CMS by ID", map[string]interface{}{ "id": id, "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) if err != nil { + if errors.Is(err, ErrCMSNotFound) { + return nil, ErrCMSNotFound + } s.logger.Error("Failed to get CMS by key", map[string]interface{}{ "key": key, "error": err.Error(), @@ -128,6 +135,9 @@ func (s *cmsService) Update(ctx context.Context, id string, form *CMSForm) (*CMS // Get existing CMS cms, err := s.repo.GetByID(ctx, id) if err != nil { + if errors.Is(err, ErrCMSNotFound) { + return nil, ErrCMSNotFound + } s.logger.Error("Failed to get existing CMS for update", map[string]interface{}{ "id": id, "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 errors.Is(err, ErrCMSNotFound) { + return ErrCMSNotFound + } s.logger.Error("Failed to delete CMS", map[string]interface{}{ "id": id, "error": err.Error(), diff --git a/internal/cms/validation.go b/internal/cms/validation.go new file mode 100644 index 0000000..18de8be --- /dev/null +++ b/internal/cms/validation.go @@ -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 +}