Files
Mazyar 3aeb8630e3
continuous-integration/drone/push Build is passing
Add company selection handling in customer forms with unit tests
- Introduced new functionality in `form_companies.go` to handle company selection from various input formats, including legacy `company_ids` and company summary objects.
- Implemented `UnmarshalJSON` methods for `CreateCustomerForm`, `UpdateCustomerForm`, and `AssignCompaniesForm` to support flexible company data parsing.
- Added unit tests in `form_companies_test.go` to validate the correct unmarshalling of company IDs and summaries, ensuring robust handling of different input scenarios.
- Enhanced the `GetByCompanies` method in the customer repository to directly use string company IDs, improving data retrieval efficiency.

This update significantly improves the handling of company data in customer forms, ensuring accurate processing and validation of company selections, while expanding test coverage for these functionalities.
2026-07-05 21:20:28 +03:30

491 lines
13 KiB
Go

package customer
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson"
mongopkg "go.mongodb.org/mongo-driver/v2/mongo"
)
// Repository defines the interface for customer data operations
type Repository interface {
Login(ctx context.Context, id, deviceToken string) error
Register(ctx context.Context, customer *Customer) error
Update(ctx context.Context, customer *Customer) error
UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error
UpdateStatus(ctx context.Context, id string, status CustomerStatus) error
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)
GetByCompanyID(ctx context.Context, companyID string, pagination *response.Pagination) ([]*Customer, int64, error)
Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*orm.PaginatedResult[Customer], error)
Delete(ctx context.Context, id string) error
}
// customerRepository implements the Repository interface using the MongoDB ORM
type customerRepository struct {
ormRepo orm.Repository[Customer]
collection *mongopkg.Collection
logger logger.Logger
}
func collection() string {
return "customers"
}
// NewCustomerRepository creates a new customer repository
func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Repository {
// Create indexes using the ORM's index management
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
err := mongoManager.CreateIndexes(collection(), indexes)
if err != nil {
logger.Warn("Failed to create customer indexes", map[string]interface{}{
"error": err.Error(),
})
}
// Create ORM repository
collection := mongoManager.GetCollection(collection())
ormRepo := orm.NewRepository[Customer](collection, logger)
return &customerRepository{
ormRepo: ormRepo,
collection: collection,
logger: logger,
}
}
// Register creates a new customer
func (r *customerRepository) Register(ctx context.Context, customer *Customer) error {
// Set created/updated timestamps using Unix timestamps
now := time.Now().Unix()
customer.SetCreatedAt(now)
// Use ORM to create customer
err := r.ormRepo.Create(ctx, customer)
if err != nil {
r.logger.Error("Failed to register customer", map[string]interface{}{
"error": err.Error(),
"email": customer.Email,
"username": customer.Username,
})
return err
}
r.logger.Info("Customer registered successfully", map[string]interface{}{
"customer_id": customer.ID,
"email": customer.Email,
"type": customer.Type,
})
return nil
}
// Login updates the last login timestamp
func (r *customerRepository) Login(ctx context.Context, id, deviceToken string) error {
// Get customer first
customer, err := r.GetByID(ctx, id)
if err != nil {
return err
}
// append device token to customer if not exists
// if !slices.Contains(customer.DeviceToken, deviceToken) {
customer.DeviceToken = []string{deviceToken}
// }
// Update last login timestamp
customer.LastLoginAt = &[]int64{time.Now().Unix()}[0]
customer.SetUpdatedAt(time.Now().Unix())
// Update in database
err = r.ormRepo.Update(ctx, customer)
if err != nil {
r.logger.Error("Failed to update last login", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return err
}
r.logger.Info("Last login updated successfully", map[string]interface{}{
"customer_id": id,
})
return nil
}
// GetByID retrieves a customer by ID
func (r *customerRepository) GetByID(ctx context.Context, id string) (*Customer, error) {
customer, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, ErrCustomerNotFound
}
r.logger.Error("Failed to get customer by ID", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return nil, err
}
return customer, nil
}
// GetByEmail retrieves a customer by email
func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*Customer, error) {
filter := bson.M{"email": email}
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 email", map[string]interface{}{
"error": err.Error(),
"email": email,
})
return nil, err
}
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}
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 username", map[string]interface{}{
"error": err.Error(),
"username": username,
})
return nil, err
}
return customer, nil
}
// GetByIDs retrieves customers by IDs
func (r *customerRepository) GetByIDs(ctx context.Context, ids []string) ([]Customer, error) {
objectIDs := make([]bson.ObjectID, 0)
for _, id := range ids {
objectID, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, err
}
objectIDs = append(objectIDs, objectID)
}
customers, err := r.ormRepo.FindAll(ctx, bson.M{"_id": bson.M{"$in": objectIDs}}, orm.NewPaginationBuilder().Limit(len(ids)).Build())
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, errors.New("customers not found")
}
r.logger.Error("Failed to get customers by IDs", map[string]interface{}{
"error": err.Error(),
"ids": ids,
})
return nil, err
}
return customers.Items, nil
}
// GetByRole retrieves customers by role
func (r *customerRepository) GetByRole(ctx context.Context, role CustomerRole) ([]Customer, error) {
customers, err := r.ormRepo.FindAll(ctx, bson.M{"role": role}, orm.NewPaginationBuilder().Build())
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, errors.New("customers not found")
}
r.logger.Error("Failed to get customers by role", map[string]interface{}{
"error": err.Error(),
"role": role,
})
return nil, err
}
return customers.Items, nil
}
// GetByCompanies retrieves customers by companies
func (r *customerRepository) GetByCompanies(ctx context.Context, companies []string) ([]Customer, error) {
if len(companies) == 0 {
return []Customer{}, nil
}
customers, err := r.ormRepo.FindAll(ctx, bson.M{"companies": bson.M{"$in": companies}}, orm.NewPaginationBuilder().Build())
if err != nil {
return nil, err
}
return customers.Items, nil
}
// GetByCompanyID retrieves customers by company ID with pagination
func (r *customerRepository) GetByCompanyID(ctx context.Context, companyID string, pagination *response.Pagination) ([]*Customer, int64, error) {
// Build pagination
paginationBuilder := orm.NewPaginationBuilder().
Limit(pagination.Limit).
Skip(pagination.Offset).
SortBy(pagination.SortBy, pagination.SortOrder).
Build()
// Filter by assigned company ID and active status
filter := bson.M{
"companies": companyID,
"status": bson.M{"$ne": CustomerStatusInactive},
}
// Use ORM to find customers
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder)
if err != nil {
r.logger.Error("Failed to get customers by company ID", map[string]interface{}{
"error": err.Error(),
"company_id": companyID,
"limit": pagination.Limit,
"offset": pagination.Offset,
})
return nil, 0, err
}
// Convert []Customer to []*Customer
customers := make([]*Customer, len(result.Items))
for i := range result.Items {
customers[i] = &result.Items[i]
}
return customers, result.TotalCount, nil
}
// Update updates a customer
func (r *customerRepository) Update(ctx context.Context, customer *Customer) error {
// Set updated timestamp using Unix timestamp
customer.SetUpdatedAt(time.Now().Unix())
// Use ORM to update customer
err := r.ormRepo.Update(ctx, customer)
if err != nil {
r.logger.Error("Failed to update customer", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
return err
}
r.logger.Info("Customer updated successfully", map[string]interface{}{
"customer_id": customer.ID,
"email": customer.Email,
})
return nil
}
// UpdatePasswordReset atomically persists a new password hash and audit fields.
func (r *customerRepository) UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error {
now := time.Now().Unix()
updateDoc := bson.M{
"$set": bson.M{
"password": hashedPassword,
"updated_at": now,
"updated_by": updatedBy,
},
}
objectID, err := bson.ObjectIDFromHex(id)
if err != nil {
return fmt.Errorf("invalid id format: %w", err)
}
result, err := r.collection.UpdateOne(ctx, bson.M{"_id": objectID}, updateDoc)
if err != nil {
r.logger.Error("Failed to update password reset", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return err
}
if result.MatchedCount == 0 {
return ErrCustomerNotFound
}
return nil
}
// Delete deletes a customer (hard delete)
func (r *customerRepository) Delete(ctx context.Context, id string) error {
// Get customer first
_, err := r.GetByID(ctx, id)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return errors.New("customer not found")
}
r.logger.Error("Failed to delete customer", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return err
}
// Update in database
err = r.ormRepo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete customer", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return err
}
r.logger.Info("Customer deleted successfully", map[string]interface{}{
"customer_id": id,
})
return nil
}
// Search retrieves customers with search and filters
func (r *customerRepository) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*orm.PaginatedResult[Customer], error) {
filter := bson.M{}
if form.Search != nil {
search := strings.TrimSpace(*form.Search)
if search != "" {
filter["$text"] = bson.M{"$search": search}
}
}
if form.FullName != nil {
fullName := strings.TrimSpace(*form.FullName)
if fullName != "" {
filter["full_name"] = bson.M{
"$regex": regexp.QuoteMeta(fullName),
"$options": "i",
}
}
}
if form.Email != nil {
email := strings.TrimSpace(*form.Email)
if email != "" {
filter["email"] = bson.M{
"$regex": regexp.QuoteMeta(email),
"$options": "i",
}
}
}
if form.Type != nil {
filter["type"] = *form.Type
}
if form.Status != nil {
filter["status"] = *form.Status
}
if form.Role != nil {
filter["role"] = *form.Role
}
if form.CompanyID != nil {
companyID := strings.TrimSpace(*form.CompanyID)
if companyID != "" {
filter["companies"] = companyID
}
}
mongoPagination, err := orm.BuildListPagination(
pagination.Limit,
pagination.Offset,
pagination.Cursor,
pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil {
return nil, err
}
result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if err != nil {
r.logger.Error("Failed to search customers", map[string]interface{}{
"error": err.Error(),
"search": form.Search,
})
return nil, err
}
return result, nil
}
// UpdateStatus updates customer status
func (r *customerRepository) UpdateStatus(ctx context.Context, id string, status CustomerStatus) error {
// Get customer first
customer, err := r.GetByID(ctx, id)
if err != nil {
return err
}
// Update status
customer.Status = status
customer.SetUpdatedAt(time.Now().Unix())
// Update in database
err = r.ormRepo.Update(ctx, customer)
if err != nil {
r.logger.Error("Failed to update customer status", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
"status": status,
})
return err
}
r.logger.Info("Customer status updated successfully", map[string]interface{}{
"customer_id": id,
"status": status,
})
return nil
}