Merge pull request 'Implement phone number validation for customer and user registration and updates' (#35) from Phone-Uniqueness into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/35
This commit is contained in:
m.nazemi
2026-06-07 14:41:21 +03:30
10 changed files with 667 additions and 18 deletions
+38 -3
View File
@@ -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
}
}
+2 -10
View File
@@ -53,11 +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() == "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" {
@@ -126,11 +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() == "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") {
+21
View File
@@ -25,6 +25,7 @@ type Repository interface {
GetByID(ctx context.Context, id string) (*Customer, error)
GetByEmail(ctx context.Context, email string) (*Customer, error)
GetByUsername(ctx context.Context, username string) (*Customer, error)
GetByPhone(ctx context.Context, phone string) (*Customer, error)
GetByIDs(ctx context.Context, ids []string) ([]Customer, error)
GetByRole(ctx context.Context, role CustomerRole) ([]Customer, error)
GetByCompanies(ctx context.Context, companies []string) ([]Customer, error)
@@ -50,6 +51,8 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
indexes := []orm.Index{
*orm.CreateUniqueIndex("username_idx", bson.D{{Key: "username", Value: 1}}),
*orm.CreateTextIndex("search_idx", "email", "username"),
*orm.CreateUniqueIndex("phone_idx", bson.D{{Key: "phone", Value: 1}}).
WithPartialFilterExpression(bson.M{"phone": bson.M{"$type": "string", "$ne": ""}}),
}
// Create indexes
@@ -166,6 +169,24 @@ func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*Cus
return customer, nil
}
// GetByPhone retrieves a customer by phone number
func (r *customerRepository) GetByPhone(ctx context.Context, phone string) (*Customer, error) {
filter := bson.M{"phone": phone}
customer, err := r.ormRepo.FindOne(ctx, filter)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, ErrCustomerNotFound
}
r.logger.Error("Failed to get customer by phone", map[string]interface{}{
"error": err.Error(),
"phone": phone,
})
return nil, err
}
return customer, nil
}
// GetByUsername retrieves a customer by username
func (r *customerRepository) GetByUsername(ctx context.Context, username string) (*Customer, error) {
filter := bson.M{"username": username}
+19
View File
@@ -95,6 +95,13 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
return nil, errors.New("customer with this username already exists")
}
if form.Phone != nil && *form.Phone != "" {
existingCustomer, _ = s.repository.GetByPhone(ctx, *form.Phone)
if existingCustomer != nil {
return nil, errors.New("customer with this phone number already exists")
}
}
// Check validator
if !s.validator.IsValidUsername(form.Username) {
return nil, errors.New("invalid username format")
@@ -149,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,
@@ -373,6 +383,12 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
}
if form.Phone != nil && (customer.Phone == nil || *form.Phone != *customer.Phone) {
if *form.Phone != "" {
existingCustomer, _ := s.repository.GetByPhone(ctx, *form.Phone)
if existingCustomer != nil && existingCustomer.ID.Hex() != id {
return nil, errors.New("customer with this phone number already exists")
}
}
customer.Phone = form.Phone
infoChanged = true
}
@@ -411,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,
+33 -3
View File
@@ -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
}
}
+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)
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(), "")
}
+20
View File
@@ -22,6 +22,7 @@ type Repository interface {
UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error
GetByEmail(ctx context.Context, email string) (*User, error)
GetByUsername(ctx context.Context, username string) (*User, error)
GetByPhone(ctx context.Context, phone string) (*User, error)
GetByIDs(ctx context.Context, userIDs []string) ([]User, error)
Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*orm.PaginatedResult[User], error)
}
@@ -38,6 +39,8 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
// Create indexes using the ORM's index management
indexes := []orm.Index{
*orm.CreateTextIndex("search_idx", "full_name", "email", "username"),
*orm.CreateUniqueIndex("phone_idx", bson.D{{Key: "phone", Value: 1}}).
WithPartialFilterExpression(bson.M{"phone": bson.M{"$type": "string", "$ne": ""}}),
}
// Create indexes
@@ -250,6 +253,23 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, e
return user, nil
}
// GetByPhone retrieves a user by phone number
func (r *userRepository) GetByPhone(ctx context.Context, phone string) (*User, error) {
user, err := r.ormRepo.FindOne(ctx, bson.M{"phone": phone})
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, errors.New("user not found")
}
r.logger.Error("Failed to get user by phone", map[string]interface{}{
"error": err.Error(),
"phone": phone,
})
return nil, err
}
return user, nil
}
// GetByUsername retrieves a user by username
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*User, error) {
user, err := r.ormRepo.FindOne(ctx, bson.M{"username": username})
+30 -2
View File
@@ -77,6 +77,13 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User
return nil, errors.New("username already exists")
}
if form.Phone != nil && *form.Phone != "" {
existingUser, _ = s.repository.GetByPhone(ctx, *form.Phone)
if existingUser != nil {
return nil, errors.New("phone number already exists")
}
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
if err != nil {
@@ -110,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,
@@ -184,7 +194,13 @@ 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 {
return nil, errors.New("phone number already exists")
}
}
user.Phone = form.Phone
}
@@ -195,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,
@@ -573,7 +592,13 @@ 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 {
return errors.New("phone number already exists")
}
}
user.Phone = form.Phone
}
if form.ProfileImage != nil {
@@ -586,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,
+192
View File
@@ -0,0 +1,192 @@
package mongo
import (
"errors"
"regexp"
"strings"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
var dupKeyFieldNamePattern = regexp.MustCompile(`(?:^|[,{]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`)
// IsDuplicateKeyError reports whether err is a MongoDB E11000 duplicate key error.
func IsDuplicateKeyError(err error) bool {
if err == nil {
return false
}
if len(duplicateKeyMessages(err)) > 0 {
return true
}
if hasStructuredWriteException(err) {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "e11000") || strings.Contains(msg, "duplicate key")
}
// DuplicateKeyMatchesField reports whether a duplicate key error involves the given field
// by parsing WriteException messages (index name and dup key document keys), not broad
// substring matching on the full error text.
func DuplicateKeyMatchesField(err error, field string) bool {
if err == nil || field == "" {
return false
}
field = strings.ToLower(field)
msgs := duplicateKeyMessages(err)
if len(msgs) == 0 {
if hasStructuredWriteException(err) {
return false
}
if !IsDuplicateKeyError(err) {
return false
}
msgs = []string{err.Error()}
}
for _, msg := range msgs {
if duplicateKeyMessageMatchesField(msg, field) {
return true
}
}
return false
}
func duplicateKeyMessages(err error) []string {
var msgs []string
var writeException mongodriver.WriteException
if errors.As(err, &writeException) {
for _, writeErr := range writeException.WriteErrors {
if writeErr.Code == 11000 {
msgs = append(msgs, writeErr.Message)
}
}
}
var bulkWriteException mongodriver.BulkWriteException
if errors.As(err, &bulkWriteException) {
for _, writeErr := range bulkWriteException.WriteErrors {
if writeErr.Code == 11000 {
msgs = append(msgs, writeErr.Message)
}
}
}
return msgs
}
func hasStructuredWriteException(err error) bool {
var writeException mongodriver.WriteException
if errors.As(err, &writeException) {
return true
}
var bulkWriteException mongodriver.BulkWriteException
return errors.As(err, &bulkWriteException)
}
func duplicateKeyMessageMatchesField(message, field string) bool {
message = strings.ToLower(message)
if indexName := extractDuplicateIndexName(message); indexName != "" {
if indexNameMatchesField(indexName, field) {
return true
}
}
for _, key := range extractDuplicateKeyFields(message) {
if key == field {
return true
}
}
return false
}
func extractDuplicateIndexName(message string) string {
const prefix = "index: "
start := strings.Index(message, prefix)
if start < 0 {
return ""
}
rest := strings.TrimSpace(message[start+len(prefix):])
if rest == "" {
return ""
}
end := strings.IndexByte(rest, ' ')
if end >= 0 {
return rest[:end]
}
return rest
}
func indexNameMatchesField(indexName, field string) bool {
switch indexName {
case field, field + "_idx", field + "_1":
return true
default:
return false
}
}
func extractDuplicateKeyFields(message string) []string {
const prefix = "dup key:"
start := strings.Index(message, prefix)
if start < 0 {
return nil
}
document := strings.TrimSpace(message[start+len(prefix):])
if !strings.HasPrefix(document, "{") {
return nil
}
depth := 0
end := -1
for i, char := range document {
switch char {
case '{':
depth++
case '}':
depth--
if depth == 0 {
end = i
}
}
if end >= 0 {
break
}
}
if end <= 0 {
return nil
}
inner := strings.TrimSpace(document[1:end])
matches := dupKeyFieldNamePattern.FindAllStringSubmatch(inner, -1)
if len(matches) == 0 {
return nil
}
fields := make([]string, 0, len(matches))
seen := make(map[string]struct{}, len(matches))
for _, match := range matches {
if len(match) < 2 {
continue
}
key := strings.ToLower(match[1])
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
fields = append(fields, key)
}
return fields
}
+303
View File
@@ -0,0 +1,303 @@
package mongo
import (
"errors"
"fmt"
"testing"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
func TestIsDuplicateKeyError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{
name: "nil error",
err: nil,
want: false,
},
{
name: "unrelated error",
err: errors.New("connection refused"),
want: false,
},
{
name: "write exception duplicate key",
err: writeExceptionWithMessage(
11000,
`E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`,
),
want: true,
},
{
name: "write exception non duplicate code",
err: writeExceptionWithMessage(
121,
`Document failed validation`,
),
want: false,
},
{
name: "wrapped write exception duplicate key",
err: fmt.Errorf(
"failed to create user: %w",
writeExceptionWithMessage(
11000,
`E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }`,
),
),
want: true,
},
{
name: "bulk write exception duplicate key",
err: bulkWriteExceptionWithMessage(
11000,
`E11000 duplicate key error collection: tm.customers index: phone_idx dup key: { phone: "+1234567890" }`,
),
want: true,
},
{
name: "string fallback duplicate key",
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }] `),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := IsDuplicateKeyError(tt.err); got != tt.want {
t.Fatalf("IsDuplicateKeyError() = %v, want %v", got, tt.want)
}
})
}
}
func TestDuplicateKeyMatchesField(t *testing.T) {
t.Parallel()
const phoneMsg = `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`
const usernameMsg = `E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }`
const emailMsg = `E11000 duplicate key error collection: tm.customers index: email_1 dup key: { email: "user@example.com" }`
const phoneValueContainsNameMsg = `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "username-like-value" }`
tests := []struct {
name string
err error
field string
want bool
}{
{
name: "nil error",
err: nil,
field: "phone",
want: false,
},
{
name: "empty field",
err: writeExceptionWithMessage(11000, phoneMsg),
field: "",
want: false,
},
{
name: "phone duplicate matches phone field",
err: writeExceptionWithMessage(11000, phoneMsg),
field: "phone",
want: true,
},
{
name: "phone duplicate does not match email field",
err: writeExceptionWithMessage(11000, phoneMsg),
field: "email",
want: false,
},
{
name: "username duplicate matches username field",
err: writeExceptionWithMessage(11000, usernameMsg),
field: "username",
want: true,
},
{
name: "username duplicate does not match name substring",
err: writeExceptionWithMessage(11000, usernameMsg),
field: "name",
want: false,
},
{
name: "email duplicate matches email field via default index suffix",
err: writeExceptionWithMessage(11000, emailMsg),
field: "email",
want: true,
},
{
name: "dup key value substring does not match unrelated field",
err: writeExceptionWithMessage(11000, phoneValueContainsNameMsg),
field: "name",
want: false,
},
{
name: "wrapped duplicate key matches field",
err: fmt.Errorf(
"failed to register customer: %w",
writeExceptionWithMessage(11000, phoneMsg),
),
field: "phone",
want: true,
},
{
name: "non duplicate write error does not match",
err: writeExceptionWithMessage(121, phoneMsg),
field: "phone",
want: false,
},
{
name: "unrelated error does not match",
err: errors.New("timeout"),
field: "phone",
want: false,
},
{
name: "string fallback duplicate key matches field",
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }] `),
field: "phone",
want: true,
},
{
name: "string fallback duplicate key does not match unrelated field",
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }] `),
field: "name",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := DuplicateKeyMatchesField(tt.err, tt.field); got != tt.want {
t.Fatalf("DuplicateKeyMatchesField(%q) = %v, want %v", tt.field, got, tt.want)
}
})
}
}
func TestExtractDuplicateIndexName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
want string
}{
{
name: "phone index",
message: `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`,
want: "phone_idx",
},
{
name: "missing index segment",
message: `E11000 duplicate key error collection: tm.users dup key: { phone: "+1234567890" }`,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := extractDuplicateIndexName(tt.message); got != tt.want {
t.Fatalf("extractDuplicateIndexName() = %q, want %q", got, tt.want)
}
})
}
}
func TestExtractDuplicateKeyFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
want []string
}{
{
name: "single field",
message: `index: phone_idx dup key: { phone: "+1234567890" }`,
want: []string{"phone"},
},
{
name: "compound dup key",
message: `index: user_company_idx dup key: { user_id: ObjectId('507f1f77bcf86cd799439011'), company_id: ObjectId('507f1f77bcf86cd799439012') }`,
want: []string{"user_id", "company_id"},
},
{
name: "value containing colon does not add false field",
message: `index: phone_idx dup key: { phone: "office:123" }`,
want: []string{"phone"},
},
{
name: "missing dup key segment",
message: `index: phone_idx`,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := extractDuplicateKeyFields(tt.message)
if len(got) != len(tt.want) {
t.Fatalf("extractDuplicateKeyFields() = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("extractDuplicateKeyFields() = %v, want %v", got, tt.want)
}
}
})
}
}
func TestIndexNameMatchesField(t *testing.T) {
t.Parallel()
tests := []struct {
indexName string
field string
want bool
}{
{indexName: "phone_idx", field: "phone", want: true},
{indexName: "phone_1", field: "phone", want: true},
{indexName: "phone", field: "phone", want: true},
{indexName: "username_idx", field: "name", want: false},
{indexName: "email_idx", field: "mail", want: false},
}
for _, tt := range tests {
t.Run(tt.indexName+"_"+tt.field, func(t *testing.T) {
t.Parallel()
if got := indexNameMatchesField(tt.indexName, tt.field); got != tt.want {
t.Fatalf("indexNameMatchesField(%q, %q) = %v, want %v", tt.indexName, tt.field, got, tt.want)
}
})
}
}
func writeExceptionWithMessage(code int, message string) error {
return mongodriver.WriteException{
WriteErrors: []mongodriver.WriteError{
{Code: code, Message: message},
},
}
}
func bulkWriteExceptionWithMessage(code int, message string) error {
return mongodriver.BulkWriteException{
WriteErrors: []mongodriver.BulkWriteError{
{WriteError: mongodriver.WriteError{Code: code, Message: message}},
},
}
}