From 31627a1bbe3add89fcce99d7d546e5503d89115f Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 31 May 2026 02:17:46 +0330 Subject: [PATCH 1/4] 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 +} From 73d2d4927a3c37bde2a390e62ae8486eed3b173a Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 31 May 2026 02:37:06 +0330 Subject: [PATCH 2/4] Add username existence check in customer registration and update handlers --- internal/customer/handler.go | 2 ++ internal/customer/service.go | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/internal/customer/handler.go b/internal/customer/handler.go index 410296d..52bdf7a 100644 --- a/internal/customer/handler.go +++ b/internal/customer/handler.go @@ -54,6 +54,7 @@ func (h *Handler) CreateCustomer(c echo.Context) error { customer, err := h.service.Register(c.Request().Context(), form) if err != nil { if err.Error() == "customer with this email already exists" || + err.Error() == "customer with this username already exists" || err.Error() == "company with this name already exists" || err.Error() == "company with this registration number already exists" || err.Error() == "company with this tax ID already exists" { @@ -126,6 +127,7 @@ func (h *Handler) UpdateCustomer(c echo.Context) error { return response.NotFound(c, "Customer not found") } if err.Error() == "customer with this email already exists" || + err.Error() == "customer with this username already exists" || err.Error() == "company with this name already exists" || err.Error() == "company with this registration number already exists" || err.Error() == "company with this tax ID already exists" { diff --git a/internal/customer/service.go b/internal/customer/service.go index b644487..4cd6dba 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -89,6 +89,12 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm return nil, errors.New("customer with this email already exists") } + // Check if username already exists + existingCustomer, _ = s.repository.GetByUsername(ctx, strings.ToLower(form.Username)) + if existingCustomer != nil { + return nil, errors.New("customer with this username already exists") + } + // Check validator if !s.validator.IsValidUsername(form.Username) { return nil, errors.New("invalid username format") From ef57b302d97ac3b08383107d8bee2cf5ad772df3 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 31 May 2026 03:05:40 +0330 Subject: [PATCH 3/4] in-app notification bug fix --- internal/notification/form.go | 2 +- internal/notification/handler.go | 4 +- internal/notification/repository.go | 39 ++++-- internal/notification/service.go | 199 ++++++++++++++-------------- 4 files changed, 134 insertions(+), 110 deletions(-) diff --git a/internal/notification/form.go b/internal/notification/form.go index e78dfb9..7878db1 100644 --- a/internal/notification/form.go +++ b/internal/notification/form.go @@ -25,7 +25,7 @@ type SearchForm struct { Method string `query:"method" valid:"optional"` EventType string `query:"event_type" valid:"optional"` Type string `query:"type" valid:"optional"` - Recipient string `query:"recipient" valid:"optional"` + Recipient []string `query:"recipient" json:"recipient" valid:"optional"` Seen *bool `query:"seen" valid:"optional"` Priority string `query:"priority" valid:"optional"` } diff --git a/internal/notification/handler.go b/internal/notification/handler.go index 7983d24..74922ae 100644 --- a/internal/notification/handler.go +++ b/internal/notification/handler.go @@ -143,7 +143,7 @@ func (h *NotificationHandler) AdminNotifications(c echo.Context) error { return response.ValidationError(c, err.Error(), "") } - form.Recipient = userID + form.Recipient = []string{userID} form.Status = "sent" pagination, err := response.NewPagination(c) @@ -261,7 +261,7 @@ func (h *NotificationHandler) CustomerNotifications(c echo.Context) error { return response.ValidationError(c, err.Error(), "") } - form.Recipient = userID + form.Recipient = []string{userID} form.Status = "sent" pagination, err := response.NewPagination(c) diff --git a/internal/notification/repository.go b/internal/notification/repository.go index 2f6179c..98f9c9f 100644 --- a/internal/notification/repository.go +++ b/internal/notification/repository.go @@ -19,7 +19,7 @@ var ( // Notification represents a stored notification in the database type Notification struct { - ID string `bson:"_id,omitempty" json:"id"` + orm.Model `bson:",inline"` UserID string `bson:"user_id" json:"user_id"` Title string `bson:"title" json:"title"` Message string `bson:"message" json:"message"` @@ -32,19 +32,27 @@ type Notification struct { Methods map[string]string `bson:"methods,omitempty" json:"methods,omitempty"` Metadata map[string]any `bson:"metadata,omitempty" json:"metadata,omitempty"` ScheduleAt int64 `bson:"schedule_at,omitempty" json:"schedule_at,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` Seen bool `bson:"seen" json:"seen"` SeenAt int64 `bson:"seen_at,omitempty" json:"seen_at,omitempty"` IsScheduled bool `bson:"is_scheduled" json:"is_scheduled"` ScheduledAt int64 `bson:"scheduled_at,omitempty" json:"scheduled_at,omitempty"` } +// SetID sets the notification ID (implements IDSetter interface) +func (n *Notification) SetID(id string) { + n.Model.SetID(id) +} + +// GetID returns the notification ID (implements IDGetter interface) +func (n *Notification) GetID() string { + return n.Model.GetID() +} + // Repository defines methods for notification data access type Repository interface { Create(ctx context.Context, notification *Notification) error GetByID(ctx context.Context, id string) (*Notification, error) - GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) + GetByUserID(ctx context.Context, userIDs []string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) Update(ctx context.Context, notification *Notification) error MarkAsSeen(ctx context.Context, notificationID, userID string) error MarkAllAsSeen(ctx context.Context, userID string) error @@ -106,9 +114,15 @@ func (r *notificationRepository) GetByID(ctx context.Context, id string) (*Notif return r.ormRepo.FindByID(ctx, id) } -// GetByUserID retrieves notifications for a specific user with filters -func (r *notificationRepository) GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) { - filter := bson.M{"user_id": userID} +// GetByUserID retrieves notifications with optional user filters +func (r *notificationRepository) GetByUserID(ctx context.Context, userIDs []string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) { + filter := bson.M{} + + if len(userIDs) == 1 { + filter["user_id"] = userIDs[0] + } else if len(userIDs) > 1 { + filter["user_id"] = bson.M{"$in": userIDs} + } if status != "" { filter["status"] = status @@ -146,8 +160,8 @@ func (r *notificationRepository) GetByUserID(ctx context.Context, userID string, result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to find notifications", map[string]interface{}{ - "error": err.Error(), - "user_id": userID, + "error": err.Error(), + "user_ids": userIDs, }) return nil, err } @@ -173,8 +187,13 @@ func (r *notificationRepository) Update(ctx context.Context, notification *Notif // MarkAsSeen marks a notification as seen func (r *notificationRepository) MarkAsSeen(ctx context.Context, notificationID, userID string) error { + objectID, err := bson.ObjectIDFromHex(notificationID) + if err != nil { + return ErrNotificationNotFound + } + filter := bson.M{ - "_id": notificationID, + "_id": objectID, "user_id": userID, } diff --git a/internal/notification/service.go b/internal/notification/service.go index 335be65..208ce81 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -76,50 +76,7 @@ func (s *notificationService) Send(ctx context.Context, req *NotificationRequest return err } for _, recipient := range recipients { - if slices.Contains(req.Channels, DeliveryChannelPush) { - for _, v := range recipient.DeviceTokens { - s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{ - UserID: recipient.UserID, - Title: req.Title, - Message: req.Description, - Type: string(req.Type), - Priority: string(req.Priority), - EventType: notification.EventTypePush, - Link: link, - Image: image, - Metadata: map[string]any{ - "tender": strings.TrimSpace(req.Tender), - }, - Methods: notification.NotificationMethods{ - Push: v, - }, - - ScheduledAt: req.ScheduleAt, - }) - } - } - - if slices.Contains(req.Channels, DeliveryChannelEmail) { - if recipient.Email != "" { - s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{ - UserID: recipient.UserID, - Title: req.Title, - Message: req.Description, - Type: string(req.Type), - Priority: string(req.Priority), - EventType: notification.EventTypeEmail, - Link: link, - Image: image, - Metadata: map[string]any{ - "tender": strings.TrimSpace(req.Tender), - }, - Methods: notification.NotificationMethods{ - Email: recipient.Email, - }, - ScheduledAt: req.ScheduleAt, - }) - } - } + s.sendToRecipient(ctx, recipient, req, link, image) } } else { recipients, err := s.getCustomers(ctx, req.Recipient, string(req.Target)) @@ -127,60 +84,104 @@ func (s *notificationService) Send(ctx context.Context, req *NotificationRequest return err } for _, recipient := range recipients { - if slices.Contains(req.Channels, DeliveryChannelPush) { - for _, v := range recipient.DeviceTokens { - s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{ - UserID: recipient.UserID, - Title: req.Title, - Message: req.Description, - Type: string(req.Type), - Priority: string(req.Priority), - EventType: notification.EventTypePush, - Link: link, - Image: image, - Methods: notification.NotificationMethods{ - Push: v, - }, - ScheduledAt: req.ScheduleAt, - Metadata: map[string]any{ - "tender": strings.TrimSpace(req.Tender), - }, - }) - } - } - - if slices.Contains(req.Channels, DeliveryChannelEmail) { - if recipient.Email != "" { - s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{ - UserID: recipient.UserID, - Title: req.Title, - Message: req.Description, - Type: string(req.Type), - Priority: string(req.Priority), - EventType: notification.EventTypeEmail, - Link: link, - Image: image, - Methods: notification.NotificationMethods{ - Email: recipient.Email, - }, - ScheduledAt: req.ScheduleAt, - Metadata: map[string]any{ - "tender": strings.TrimSpace(req.Tender), - }, - }) - } - } + s.sendToRecipient(ctx, recipient, req, link, image) } - s.logger.Info("Notification sent", map[string]interface{}{ - "recipient": req.Recipient, - "channels": req.Channels, - }) - - return nil } + + s.logger.Info("Notification sent", map[string]interface{}{ + "recipient": req.Recipient, + "channels": req.Channels, + "target": req.Target, + }) + return nil } +func (s *notificationService) sendToRecipient(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string) { + if err := s.persistNotification(ctx, recipient, req, link, image); err != nil { + s.logger.Error("Failed to persist in-app notification", map[string]interface{}{ + "error": err.Error(), + "user_id": recipient.UserID, + }) + } + + metadata := map[string]any{ + "tender": strings.TrimSpace(req.Tender), + } + + if slices.Contains(req.Channels, DeliveryChannelPush) { + for _, v := range recipient.DeviceTokens { + s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{ + UserID: recipient.UserID, + Title: req.Title, + Message: req.Description, + Type: string(req.Type), + Priority: string(req.Priority), + EventType: notification.EventTypePush, + Link: link, + Image: image, + Metadata: metadata, + Methods: notification.NotificationMethods{ + Push: v, + }, + ScheduledAt: req.ScheduleAt, + }) + } + } + + if slices.Contains(req.Channels, DeliveryChannelEmail) && recipient.Email != "" { + s.sdk.SendNotification(ctx, ¬ification.NotificationRequest{ + UserID: recipient.UserID, + Title: req.Title, + Message: req.Description, + Type: string(req.Type), + Priority: string(req.Priority), + EventType: notification.EventTypeEmail, + Link: link, + Image: image, + Metadata: metadata, + Methods: notification.NotificationMethods{ + Email: recipient.Email, + }, + ScheduledAt: req.ScheduleAt, + }) + } +} + +func (s *notificationService) persistNotification(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string) error { + methods := make(map[string]string) + if slices.Contains(req.Channels, DeliveryChannelEmail) && recipient.Email != "" { + methods["email"] = recipient.Email + } + if slices.Contains(req.Channels, DeliveryChannelPush) && len(recipient.DeviceTokens) > 0 { + methods["push"] = recipient.DeviceTokens[0] + } + + status := string(DeliveryStatusSent) + if req.ScheduleAt > 0 { + status = string(DeliveryStatusPending) + } + + return s.repository.Create(ctx, &Notification{ + UserID: recipient.UserID, + Title: req.Title, + Message: req.Description, + Link: link, + Image: image, + Priority: string(req.Priority), + Type: string(req.Type), + Status: status, + Methods: methods, + Metadata: map[string]any{ + "tender": strings.TrimSpace(req.Tender), + }, + ScheduleAt: req.ScheduleAt, + ScheduledAt: req.ScheduleAt, + IsScheduled: req.ScheduleAt > 0, + Seen: false, + }) +} + func (s *notificationService) GetNotifications(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*NotificationListResponse, error) { s.logger.Info("Getting notifications", map[string]interface{}{ "status": req.Status, @@ -227,7 +228,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF } notificationsResponse = append(notificationsResponse, &NotificationResponse{ - ID: notification.ID, + ID: notification.GetID(), UserID: notification.UserID, Recipient: recipient, Title: notification.Title, @@ -292,7 +293,7 @@ func (s *notificationService) GetNotification(ctx context.Context, notificationI } detailedResponse := &NotificationResponse{ - ID: notification.ID, + ID: notification.GetID(), UserID: notification.UserID, Recipient: recipient, Title: notification.Title, @@ -378,11 +379,15 @@ func (s *notificationService) getUsers(ctx context.Context, userIDs []string) ([ }) } - if len(recipients) == 0 { - return nil, errors.New("no users found") + if len(userIDs) > 0 || len(users.Users) == 0 { + break } } + if len(recipients) == 0 { + return nil, errors.New("no users found") + } + return recipients, nil } From 2977f470acf9f4daf34a177857c54bff72b5a3dd Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 31 May 2026 03:32:07 +0330 Subject: [PATCH 4/4] Add customer feedback statistics endpoint and refactor feedback handling --- cmd/web/router/routes.go | 1 + internal/feedback/form.go | 9 ++ internal/feedback/handler.go | 62 ++++++++----- internal/feedback/repository.go | 150 ++++++++++++++++++++++++-------- internal/feedback/service.go | 57 ++++++++++-- 5 files changed, 214 insertions(+), 65 deletions(-) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index b087183..fa4c3fb 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -265,6 +265,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende feedbackGP.GET("", feedbackHandler.PublicList) feedbackGP.GET("/tender/:tender_id", feedbackHandler.PublicGetByTenderID) feedbackGP.GET("/:id", feedbackHandler.PublicGet) + feedbackGP.GET("/stats/customer", feedbackHandler.GetCustomerStats) feedbackGP.GET("/stats/company", feedbackHandler.GetCompanyStats) } diff --git a/internal/feedback/form.go b/internal/feedback/form.go index 06a39ee..4e19579 100644 --- a/internal/feedback/form.go +++ b/internal/feedback/form.go @@ -87,3 +87,12 @@ type StatsResponse struct { TotalFeedback int64 `json:"total_feedback"` LastUpdated int64 `json:"last_updated"` } + +// CustomerStatsResponse represents feedback statistics for a single customer. +type CustomerStatsResponse struct { + CustomerID string `json:"customer_id"` + TotalLikes int64 `json:"total_likes"` + TotalDislikes int64 `json:"total_dislikes"` + TotalFeedback int64 `json:"total_feedback"` + LastUpdated int64 `json:"last_updated"` +} diff --git a/internal/feedback/handler.go b/internal/feedback/handler.go index f1e8d32..81bb12a 100644 --- a/internal/feedback/handler.go +++ b/internal/feedback/handler.go @@ -195,7 +195,7 @@ func (h *Handler) Toggle(c echo.Context) error { // ListFeedback retrieves feedback with pagination and filtering // @Summary List feedback with filters -// @Description Retrieve paginated list of feedback with optional filtering by various criteria. This endpoint is for public use and filters results based on the authenticated user's company. +// @Description Retrieve paginated list of feedback for the authenticated customer with optional filters. // @Tags Feedback // @Accept json // @Produce json @@ -214,16 +214,16 @@ func (h *Handler) Toggle(c echo.Context) error { // @Security BearerAuth // @Router /api/v1/feedback [get] func (h *Handler) PublicList(c echo.Context) error { - companyID, err := user.GetCompanyIDFromContext(c) + customerID, err := customer.GetCustomerIDFromContext(c) if err != nil { - return response.BadRequest(c, "Company ID required", "User must be associated with a company") + return response.BadRequest(c, "Customer ID required", "User must be associated with a customer") } form, err := response.Parse[SearchForm](c) if err != nil { return response.ValidationError(c, "Invalid request data", err.Error()) } - form.Company = &companyID + form.Customer = &customerID pagination, err := response.NewPagination(c) if err != nil { @@ -287,12 +287,12 @@ func (h *Handler) PublicGetByTenderID(c echo.Context) error { return response.BadRequest(c, "Tender ID is required", "Tender ID parameter is missing") } - companyID, err := user.GetCompanyIDFromContext(c) + customerID, err := customer.GetCustomerIDFromContext(c) if err != nil { - return response.BadRequest(c, "Company ID required", "User must be associated with a company") + return response.BadRequest(c, "Customer ID required", "User must be associated with a customer") } - feedback, err := h.service.GetByTenderID(c.Request().Context(), tenderID, companyID) + feedback, err := h.service.GetByTenderIDForCustomer(c.Request().Context(), tenderID, customerID) if err != nil { return response.BadRequest(c, "Failed to get feedback", "") } @@ -300,28 +300,44 @@ func (h *Handler) PublicGetByTenderID(c echo.Context) error { return response.Success(c, feedback, "Feedback retrieved successfully") } -// GetCompanyFeedbackStats retrieves feedback statistics for the authenticated user's company -// @Summary Get company feedback statistics -// @Description Retrieve comprehensive feedback statistics for the authenticated user's company including total likes, dislikes, and percentage calculations. +// GetCustomerStats retrieves feedback statistics for the authenticated customer. +// @Summary Get customer feedback statistics +// @Description Retrieve feedback statistics (likes, dislikes, totals) for the authenticated customer only. // @Tags Feedback // @Accept json // @Produce json -// @Success 200 {object} response.APIResponse{data=StatsResponse} "Company feedback statistics retrieved successfully" -// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Missing company association" +// @Success 200 {object} response.APIResponse{data=CustomerStatsResponse} "Customer feedback statistics retrieved successfully" +// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Missing customer association" +// @Failure 401 {object} response.APIResponse{error=string,message=string} "Unauthorized - User not authenticated" +// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error" +// @Security BearerAuth +// @Router /api/v1/feedback/stats/customer [get] +func (h *Handler) GetCustomerStats(c echo.Context) error { + customerID, err := customer.GetCustomerIDFromContext(c) + if err != nil { + return response.BadRequest(c, "Customer ID required", "User must be associated with a customer") + } + + stats, err := h.service.CustomerStats(c.Request().Context(), customerID) + if err != nil { + return response.InternalServerError(c, "Failed to retrieve customer stats") + } + + return response.Success(c, stats, "Customer stats retrieved successfully") +} + +// GetCompanyStats returns customer-scoped stats for backward compatibility with clients using /stats/company. +// @Summary Get feedback statistics (customer-scoped, legacy path) +// @Description Same as GET /feedback/stats/customer. Prefer /stats/customer for new integrations. +// @Tags Feedback +// @Accept json +// @Produce json +// @Success 200 {object} response.APIResponse{data=CustomerStatsResponse} "Customer feedback statistics retrieved successfully" +// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Missing customer association" // @Failure 401 {object} response.APIResponse{error=string,message=string} "Unauthorized - User not authenticated" // @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error" // @Security BearerAuth // @Router /api/v1/feedback/stats/company [get] func (h *Handler) GetCompanyStats(c echo.Context) error { - companyID, err := user.GetCompanyIDFromContext(c) - if err != nil { - return response.BadRequest(c, "Company ID required", "User must be associated with a company") - } - - stats, err := h.service.CompanyStats(c.Request().Context(), companyID) - if err != nil { - return response.InternalServerError(c, "Failed to retrieve company stats") - } - - return response.Success(c, stats, "Company stats retrieved successfully") + return h.GetCustomerStats(c) } diff --git a/internal/feedback/repository.go b/internal/feedback/repository.go index 321d138..d61f9f4 100644 --- a/internal/feedback/repository.go +++ b/internal/feedback/repository.go @@ -19,9 +19,12 @@ type Repository interface { Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Feedback], error) GetByID(ctx context.Context, id string) (*Feedback, error) GetByCompanyID(ctx context.Context, companyID string, tender string) (*Feedback, error) + GetByCustomerID(ctx context.Context, customerID string, tenderID string) (*Feedback, error) GetByTenderID(ctx context.Context, tenderID, companyID string) (*Feedback, error) + GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*Feedback, error) Delete(ctx context.Context, id string) error CompanyStats(ctx context.Context, companyID string) (*StatsResponse, error) + CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) } // feedbackRepository implements FeedbackRepository interface using MongoDB ORM @@ -42,6 +45,11 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re {Key: "tender_id", Value: 1}, {Key: "company_id", Value: 1}, }), + *orm.NewIndex("customer_id_idx", bson.D{{Key: "customer_id", Value: 1}}), + *orm.NewIndex("tender_customer_idx", bson.D{ + {Key: "tender_id", Value: 1}, + {Key: "customer_id", Value: 1}, + }), *orm.NewIndex("tender_type_idx", bson.D{ {Key: "tender_id", Value: 1}, {Key: "feedback_type", Value: 1}, @@ -140,6 +148,23 @@ func (r *feedbackRepository) GetByCompanyID(ctx context.Context, companyID strin return result, nil } +// GetByCustomerID retrieves feedback by customer ID and tender ID. +func (r *feedbackRepository) GetByCustomerID(ctx context.Context, customerID string, tenderID string) (*Feedback, error) { + filter := bson.M{"customer_id": customerID, "tender_id": tenderID} + + result, err := r.repo.FindOne(ctx, filter) + if err != nil { + r.logger.Error("Failed to get feedback by customer ID and tender ID", map[string]interface{}{ + "customer_id": customerID, + "tender_id": tenderID, + "error": err.Error(), + }) + return nil, err + } + + return result, nil +} + // GetByTenderID retrieves feedback by tender ID and company ID func (r *feedbackRepository) GetByTenderID(ctx context.Context, tenderID, companyID string) (*Feedback, error) { filter := bson.M{"tender_id": tenderID, "company_id": companyID} @@ -157,6 +182,23 @@ func (r *feedbackRepository) GetByTenderID(ctx context.Context, tenderID, compan return result, nil } +// GetByTenderIDForCustomer retrieves feedback by tender ID and customer ID. +func (r *feedbackRepository) GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*Feedback, error) { + filter := bson.M{"tender_id": tenderID, "customer_id": customerID} + + result, err := r.repo.FindOne(ctx, filter) + if err != nil { + r.logger.Error("Failed to get feedback by tender ID and customer ID", map[string]interface{}{ + "tender_id": tenderID, + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + return result, nil +} + // Delete deletes a feedback by ID func (r *feedbackRepository) Delete(ctx context.Context, id string) error { err := r.repo.Delete(ctx, id) @@ -199,20 +241,59 @@ func (r *feedbackRepository) Search(ctx context.Context, criteria SearchForm, pa return result, nil } -// Stats calculates feedback statistics for a company +// CompanyStats calculates feedback statistics for a company. func (r *feedbackRepository) CompanyStats(ctx context.Context, companyID string) (*StatsResponse, error) { + counts, lastUpdated, err := r.aggregateFeedbackCounts(ctx, bson.M{"company_id": companyID}) + if err != nil { + r.logger.Error("Failed to get feedback stats", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, err + } + + return &StatsResponse{ + CompanyID: companyID, + TotalFeedback: counts.totalFeedback, + TotalLikes: counts.totalLikes, + TotalDislikes: counts.totalDislikes, + LastUpdated: lastUpdated, + }, nil +} + +// CustomerStats calculates feedback statistics for a customer. +func (r *feedbackRepository) CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) { + counts, lastUpdated, err := r.aggregateFeedbackCounts(ctx, bson.M{"customer_id": customerID}) + if err != nil { + r.logger.Error("Failed to get customer feedback stats", map[string]interface{}{ + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + return &CustomerStatsResponse{ + CustomerID: customerID, + TotalFeedback: counts.totalFeedback, + TotalLikes: counts.totalLikes, + TotalDislikes: counts.totalDislikes, + LastUpdated: lastUpdated, + }, nil +} + +type feedbackCountAggregate struct { + totalFeedback int64 + totalLikes int64 + totalDislikes int64 +} + +func (r *feedbackRepository) aggregateFeedbackCounts(ctx context.Context, match bson.M) (feedbackCountAggregate, int64, error) { pipeline := mongo.Pipeline{ - { - {Key: "$match", Value: bson.M{ - "company_id": companyID, - }}, - }, + {{Key: "$match", Value: match}}, { {Key: "$group", Value: bson.M{ "_id": nil, - "total_feedback": bson.M{ - "$sum": 1, - }, + "total_feedback": bson.M{"$sum": 1}, "total_likes": bson.M{ "$sum": bson.M{ "$cond": []interface{}{ @@ -238,38 +319,37 @@ func (r *feedbackRepository) CompanyStats(ctx context.Context, companyID string) results, err := r.repo.Aggregate(ctx, pipeline) if err != nil { - r.logger.Error("Failed to get feedback stats", map[string]interface{}{ - "company_id": companyID, - "error": err.Error(), - }) - return nil, err + return feedbackCountAggregate{}, time.Now().Unix(), err } - stats := &StatsResponse{ - CompanyID: companyID, - TotalFeedback: 0, - TotalLikes: 0, - TotalDislikes: 0, - LastUpdated: time.Now().Unix(), + counts := feedbackCountAggregate{} + lastUpdated := time.Now().Unix() + if len(results) == 0 { + return counts, lastUpdated, nil } - if len(results) > 0 { - result := results[0] - if totalFeedback, ok := result["total_feedback"].(int32); ok { - stats.TotalFeedback = int64(totalFeedback) - } - if totalLikes, ok := result["total_likes"].(int32); ok { - stats.TotalLikes = int64(totalLikes) - } - if totalDislikes, ok := result["total_dislikes"].(int32); ok { - stats.TotalDislikes = int64(totalDislikes) - } - if lastUpdated, ok := result["last_updated"].(int64); ok { - stats.LastUpdated = lastUpdated - } + result := results[0] + counts.totalFeedback = aggregateInt64(result["total_feedback"]) + counts.totalLikes = aggregateInt64(result["total_likes"]) + counts.totalDislikes = aggregateInt64(result["total_dislikes"]) + if v, ok := result["last_updated"].(int64); ok && v > 0 { + lastUpdated = v } - return stats, nil + return counts, lastUpdated, nil +} + +func aggregateInt64(value interface{}) int64 { + switch v := value.(type) { + case int32: + return int64(v) + case int64: + return v + case float64: + return int64(v) + default: + return 0 + } } // buildSearchFilter builds MongoDB filter from search criteria diff --git a/internal/feedback/service.go b/internal/feedback/service.go index d17a6c5..4201786 100644 --- a/internal/feedback/service.go +++ b/internal/feedback/service.go @@ -14,8 +14,10 @@ type Service interface { Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*FeedbackListResponse, error) Get(ctx context.Context, id string) (*FeedbackResponse, error) GetByTenderID(ctx context.Context, tenderID, companyID string) (*FeedbackResponse, error) + GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*FeedbackResponse, error) Delete(ctx context.Context, id string) error CompanyStats(ctx context.Context, companyID string) (*StatsResponse, error) + CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) getDetails(ctx context.Context, tenderID, companyID string) (*tenderResponse, *companyResponse) } @@ -38,7 +40,7 @@ func NewService(feedbackRepo Repository, tenderService tender.Service, companySe } func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, customerID *string, companyID *string) (*FeedbackResponse, error) { - existing, err := s.feedbackRepo.GetByCompanyID(ctx, *companyID, req.Tender) + existing, err := s.feedbackRepo.GetByCustomerID(ctx, *customerID, req.Tender) if err != nil { feedback := &Feedback{ @@ -51,9 +53,10 @@ func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, cu err = s.feedbackRepo.Create(ctx, feedback) if err != nil { s.logger.Error("Failed to create new feedback", map[string]interface{}{ - "tender_id": req.Tender, - "company_id": *companyID, - "error": err.Error(), + "tender_id": req.Tender, + "customer_id": *customerID, + "company_id": *companyID, + "error": err.Error(), }) return nil, err } @@ -66,10 +69,10 @@ func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, cu if existing.FeedbackType == req.Type { err = s.feedbackRepo.Delete(ctx, existing.GetID()) if err != nil { - s.logger.Error("Failed to update existing feedback", map[string]interface{}{ + s.logger.Error("Failed to delete existing feedback", map[string]interface{}{ "feedback_id": existing.GetID(), "tender_id": req.Tender, - "company_id": *companyID, + "customer_id": *customerID, "error": err.Error(), }) return nil, err @@ -97,7 +100,7 @@ func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, cu s.logger.Error("Failed to update existing feedback", map[string]interface{}{ "feedback_id": existing.GetID(), "tender_id": req.Tender, - "company_id": *companyID, + "customer_id": *customerID, "error": err.Error(), }) return nil, err @@ -177,6 +180,22 @@ func (s *feedbackService) GetByTenderID(ctx context.Context, tenderID, companyID return feedback.ToResponse(tender, company), nil } +func (s *feedbackService) GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*FeedbackResponse, error) { + feedback, err := s.feedbackRepo.GetByTenderIDForCustomer(ctx, tenderID, customerID) + if err != nil { + s.logger.Error("Failed to get feedback by tender ID for customer", map[string]interface{}{ + "tender_id": tenderID, + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + tender, company := s.getDetails(ctx, feedback.TenderID, feedbackCompanyID(*feedback)) + + return feedback.ToResponse(tender, company), nil +} + func feedbackCompanyID(f Feedback) string { if f.CompanyID == nil { return "" @@ -346,3 +365,27 @@ func (s *feedbackService) CompanyStats(ctx context.Context, companyID string) (* return stats, nil } + +func (s *feedbackService) CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) { + s.logger.Info("Getting customer feedback statistics", map[string]interface{}{ + "customer_id": customerID, + }) + + stats, err := s.feedbackRepo.CustomerStats(ctx, customerID) + if err != nil { + s.logger.Error("Failed to get customer stats", map[string]interface{}{ + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + s.logger.Info("Customer stats retrieved successfully", map[string]interface{}{ + "customer_id": customerID, + "total_feedback": stats.TotalFeedback, + "total_likes": stats.TotalLikes, + "total_dislikes": stats.TotalDislikes, + }) + + return stats, nil +}