From 22487eaef58b7f728e7c4288660f1f2d949359a3 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sat, 6 Jun 2026 20:34:45 +0330 Subject: [PATCH] fix(user,customer): return 409 on phone duplicate-key races Map E11000 from the per-collection phone index to friendly conflict errors and skip redundant GetByPhone when the phone is unchanged. Not blocking: IAT test DB may need a one-off dedupe for index build; phone uniqueness is per collection (users vs customers), not global. --- internal/customer/errors.go | 41 ++++++++++++++++++++++++++++++--- internal/customer/handler.go | 14 ++---------- internal/customer/service.go | 6 +++++ internal/user/errors.go | 36 ++++++++++++++++++++++++++--- internal/user/handler.go | 9 ++++++++ internal/user/service.go | 13 +++++++++-- pkg/mongo/duplicate.go | 44 ++++++++++++++++++++++++++++++++++++ 7 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 pkg/mongo/duplicate.go diff --git a/internal/customer/errors.go b/internal/customer/errors.go index 28c0e8d..ad99cbb 100644 --- a/internal/customer/errors.go +++ b/internal/customer/errors.go @@ -1,7 +1,42 @@ package customer -import "errors" +import ( + "errors" -var ( - ErrCustomerNotFound = errors.New("customer not found") + orm "tm/pkg/mongo" ) + +var ErrCustomerNotFound = errors.New("customer not found") + +func mapCustomerWriteError(err error) error { + if err == nil || !orm.IsDuplicateKeyError(err) { + return err + } + switch { + case orm.DuplicateKeyMatchesField(err, "phone"): + return errors.New("customer with this phone number already exists") + case orm.DuplicateKeyMatchesField(err, "email"): + return errors.New("customer with this email already exists") + case orm.DuplicateKeyMatchesField(err, "username"): + return errors.New("customer with this username already exists") + default: + return err + } +} + +func isCustomerConflictError(err error) bool { + if err == nil { + return false + } + switch err.Error() { + case "customer with this phone number already exists", + "customer with this email already exists", + "customer with this username already exists", + "company with this name already exists", + "company with this registration number already exists", + "company with this tax ID already exists": + return true + default: + return false + } +} diff --git a/internal/customer/handler.go b/internal/customer/handler.go index b7c4de2..a499f8e 100644 --- a/internal/customer/handler.go +++ b/internal/customer/handler.go @@ -53,12 +53,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() == "customer with this phone number 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" { + if isCustomerConflictError(err) { return response.Conflict(c, err.Error()) } if strings.HasPrefix(err.Error(), "invalid company ID") || err.Error() == "invalid username format" { @@ -127,12 +122,7 @@ func (h *Handler) UpdateCustomer(c echo.Context) error { if err.Error() == "customer not found" { 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() == "customer with this phone number 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" { + if isCustomerConflictError(err) { return response.Conflict(c, err.Error()) } if strings.HasPrefix(err.Error(), "invalid company ID") { diff --git a/internal/customer/service.go b/internal/customer/service.go index 3a88166..8818064 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -156,6 +156,9 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm // Save to database err = s.repository.Register(ctx, customer) if err != nil { + if mapped := mapCustomerWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to create customer", map[string]interface{}{ "error": err.Error(), "email": form.Email, @@ -424,6 +427,9 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus // Update in database err = s.repository.Update(ctx, customer) if err != nil { + if mapped := mapCustomerWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to update customer", map[string]interface{}{ "error": err.Error(), "customer_id": id, diff --git a/internal/user/errors.go b/internal/user/errors.go index 2b9a669..1155b1b 100644 --- a/internal/user/errors.go +++ b/internal/user/errors.go @@ -1,7 +1,37 @@ package user -import "errors" +import ( + "errors" -var ( - ErrUserNotFound = errors.New("user not found") + orm "tm/pkg/mongo" ) + +var ErrUserNotFound = errors.New("user not found") + +func mapUserWriteError(err error) error { + if err == nil || !orm.IsDuplicateKeyError(err) { + return err + } + switch { + case orm.DuplicateKeyMatchesField(err, "phone"): + return errors.New("phone number already exists") + case orm.DuplicateKeyMatchesField(err, "email"): + return errors.New("email already exists") + case orm.DuplicateKeyMatchesField(err, "username"): + return errors.New("username already exists") + default: + return err + } +} + +func isUserConflictError(err error) bool { + if err == nil { + return false + } + switch err.Error() { + case "phone number already exists", "email already exists", "username already exists": + return true + default: + return false + } +} diff --git a/internal/user/handler.go b/internal/user/handler.go index f2c5960..8211152 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -170,6 +170,9 @@ func (h *Handler) UpdateProfile(c echo.Context) error { user, err := h.service.Update(c.Request().Context(), userID, form) if err != nil { + if isUserConflictError(err) { + return response.Conflict(c, err.Error()) + } return response.BadRequest(c, err.Error(), "") } @@ -274,6 +277,9 @@ func (h *Handler) CreateUser(c echo.Context) error { // Call service user, err := h.service.Register(c.Request().Context(), form) if err != nil { + if isUserConflictError(err) { + return response.Conflict(c, err.Error()) + } return response.BadRequest(c, err.Error(), "") } @@ -386,6 +392,9 @@ func (h *Handler) UpdateUser(c echo.Context) error { // Call service user, err := h.service.Update(c.Request().Context(), idStr, form) if err != nil { + if isUserConflictError(err) { + return response.Conflict(c, err.Error()) + } return response.BadRequest(c, err.Error(), "") } diff --git a/internal/user/service.go b/internal/user/service.go index 8bc6c4d..b852df9 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -117,6 +117,9 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User // Save to database err = s.repository.Create(ctx, user) if err != nil { + if mapped := mapUserWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to register user", map[string]interface{}{ "error": err.Error(), "email": form.Email, @@ -191,7 +194,7 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor user.Position = form.Position } - if form.Phone != nil { + if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) { if *form.Phone != "" { existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone) if existingUser != nil && existingUser.ID.Hex() != id { @@ -208,6 +211,9 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor // Update in database err = s.repository.Update(ctx, user) if err != nil { + if mapped := mapUserWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to update user", map[string]interface{}{ "error": err.Error(), "user_id": id, @@ -586,7 +592,7 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up if form.Position != nil { user.Position = form.Position } - if form.Phone != nil { + if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) { if *form.Phone != "" { existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone) if existingUser != nil && existingUser.ID.Hex() != userID { @@ -605,6 +611,9 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up // Save to database err = s.repository.Update(ctx, user) if err != nil { + if mapped := mapUserWriteError(err); mapped != err { + return mapped + } s.logger.Error("Failed to update user profile", map[string]interface{}{ "error": err.Error(), "user_id": userID, diff --git a/pkg/mongo/duplicate.go b/pkg/mongo/duplicate.go new file mode 100644 index 0000000..17359b3 --- /dev/null +++ b/pkg/mongo/duplicate.go @@ -0,0 +1,44 @@ +package mongo + +import ( + "errors" + "strings" + + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +// IsDuplicateKeyError reports whether err is a MongoDB E11000 duplicate key error. +func IsDuplicateKeyError(err error) bool { + if err == nil { + return false + } + if strings.Contains(err.Error(), "E11000") || strings.Contains(err.Error(), "duplicate key") { + return true + } + var we mongodriver.WriteException + if errors.As(err, &we) { + for _, wErr := range we.WriteErrors { + if wErr.Code == 11000 { + return true + } + } + } + return false +} + +// DuplicateKeyMatchesField reports whether a duplicate key error involves the given field +// (e.g. "phone" matches index phone_idx or dup key { phone: "..." }). +func DuplicateKeyMatchesField(err error, field string) bool { + if err == nil || field == "" { + return false + } + msg := strings.ToLower(err.Error()) + field = strings.ToLower(field) + if strings.Contains(msg, field+"_idx") { + return true + } + if strings.Contains(msg, "dup key") && strings.Contains(msg, field) { + return true + } + return strings.Contains(msg, `"`+field+`"`) || strings.Contains(msg, field+":") +}