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.
This commit is contained in:
Mazyar
2026-06-06 20:34:45 +03:30
parent 0da0c8b8c9
commit 22487eaef5
7 changed files with 143 additions and 20 deletions
+38 -3
View File
@@ -1,7 +1,42 @@
package customer package customer
import "errors" import (
"errors"
var ( orm "tm/pkg/mongo"
ErrCustomerNotFound = errors.New("customer not found")
) )
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
}
}
+2 -12
View File
@@ -53,12 +53,7 @@ func (h *Handler) CreateCustomer(c echo.Context) error {
customer, err := h.service.Register(c.Request().Context(), form) customer, err := h.service.Register(c.Request().Context(), form)
if err != nil { if err != nil {
if err.Error() == "customer with this email already exists" || if isCustomerConflictError(err) {
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" {
return response.Conflict(c, err.Error()) return response.Conflict(c, err.Error())
} }
if strings.HasPrefix(err.Error(), "invalid company ID") || err.Error() == "invalid username format" { 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" { if err.Error() == "customer not found" {
return response.NotFound(c, "Customer not found") return response.NotFound(c, "Customer not found")
} }
if err.Error() == "customer with this email already exists" || if isCustomerConflictError(err) {
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" {
return response.Conflict(c, err.Error()) return response.Conflict(c, err.Error())
} }
if strings.HasPrefix(err.Error(), "invalid company ID") { if strings.HasPrefix(err.Error(), "invalid company ID") {
+6
View File
@@ -156,6 +156,9 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
// Save to database // Save to database
err = s.repository.Register(ctx, customer) err = s.repository.Register(ctx, customer)
if err != nil { if err != nil {
if mapped := mapCustomerWriteError(err); mapped != err {
return nil, mapped
}
s.logger.Error("Failed to create customer", map[string]interface{}{ s.logger.Error("Failed to create customer", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"email": form.Email, "email": form.Email,
@@ -424,6 +427,9 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
// Update in database // Update in database
err = s.repository.Update(ctx, customer) err = s.repository.Update(ctx, customer)
if err != nil { if err != nil {
if mapped := mapCustomerWriteError(err); mapped != err {
return nil, mapped
}
s.logger.Error("Failed to update customer", map[string]interface{}{ s.logger.Error("Failed to update customer", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"customer_id": id, "customer_id": id,
+33 -3
View File
@@ -1,7 +1,37 @@
package user package user
import "errors" import (
"errors"
var ( orm "tm/pkg/mongo"
ErrUserNotFound = errors.New("user not found")
) )
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
}
}
+9
View File
@@ -170,6 +170,9 @@ func (h *Handler) UpdateProfile(c echo.Context) error {
user, err := h.service.Update(c.Request().Context(), userID, form) user, err := h.service.Update(c.Request().Context(), userID, form)
if err != nil { if err != nil {
if isUserConflictError(err) {
return response.Conflict(c, err.Error())
}
return response.BadRequest(c, err.Error(), "") return response.BadRequest(c, err.Error(), "")
} }
@@ -274,6 +277,9 @@ func (h *Handler) CreateUser(c echo.Context) error {
// Call service // Call service
user, err := h.service.Register(c.Request().Context(), form) user, err := h.service.Register(c.Request().Context(), form)
if err != nil { if err != nil {
if isUserConflictError(err) {
return response.Conflict(c, err.Error())
}
return response.BadRequest(c, err.Error(), "") return response.BadRequest(c, err.Error(), "")
} }
@@ -386,6 +392,9 @@ func (h *Handler) UpdateUser(c echo.Context) error {
// Call service // Call service
user, err := h.service.Update(c.Request().Context(), idStr, form) user, err := h.service.Update(c.Request().Context(), idStr, form)
if err != nil { if err != nil {
if isUserConflictError(err) {
return response.Conflict(c, err.Error())
}
return response.BadRequest(c, err.Error(), "") return response.BadRequest(c, err.Error(), "")
} }
+11 -2
View File
@@ -117,6 +117,9 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User
// Save to database // Save to database
err = s.repository.Create(ctx, user) err = s.repository.Create(ctx, user)
if err != nil { if err != nil {
if mapped := mapUserWriteError(err); mapped != err {
return nil, mapped
}
s.logger.Error("Failed to register user", map[string]interface{}{ s.logger.Error("Failed to register user", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"email": form.Email, "email": form.Email,
@@ -191,7 +194,7 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor
user.Position = form.Position user.Position = form.Position
} }
if form.Phone != nil { if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) {
if *form.Phone != "" { if *form.Phone != "" {
existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone) existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone)
if existingUser != nil && existingUser.ID.Hex() != id { 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 // Update in database
err = s.repository.Update(ctx, user) err = s.repository.Update(ctx, user)
if err != nil { if err != nil {
if mapped := mapUserWriteError(err); mapped != err {
return nil, mapped
}
s.logger.Error("Failed to update user", map[string]interface{}{ s.logger.Error("Failed to update user", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"user_id": id, "user_id": id,
@@ -586,7 +592,7 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up
if form.Position != nil { if form.Position != nil {
user.Position = form.Position user.Position = form.Position
} }
if form.Phone != nil { if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) {
if *form.Phone != "" { if *form.Phone != "" {
existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone) existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone)
if existingUser != nil && existingUser.ID.Hex() != userID { 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 // Save to database
err = s.repository.Update(ctx, user) err = s.repository.Update(ctx, user)
if err != nil { if err != nil {
if mapped := mapUserWriteError(err); mapped != err {
return mapped
}
s.logger.Error("Failed to update user profile", map[string]interface{}{ s.logger.Error("Failed to update user profile", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"user_id": userID, "user_id": userID,
+44
View File
@@ -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+":")
}