Files
tm_back/internal/customer/service.go
T
Mazyar 22487eaef5 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.
2026-06-06 20:34:45 +03:30

1466 lines
45 KiB
Go

package customer
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
"tm/internal/company"
"tm/pkg/audit"
"tm/pkg/logger"
"tm/pkg/notification"
"tm/pkg/redis"
"tm/pkg/response"
"tm/pkg/authorization"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// Service defines business logic for customer operations
type Service interface {
// Core customer operations
Register(ctx context.Context, form *CreateCustomerForm) (*CustomerResponse, error)
GetByID(ctx context.Context, id string) (*CustomerResponse, error)
Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*CustomerListResponse, error)
SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error)
GetCustomersByIDs(ctx context.Context, ids []string) ([]*CustomerNotificationResponse, error)
GetCustomersByRole(ctx context.Context, role string) ([]*CustomerNotificationResponse, error)
GetCustomersByCompanies(ctx context.Context, companies []string) ([]*CustomerNotificationResponse, error)
Update(ctx context.Context, id string, form *UpdateCustomerForm) (*CustomerResponse, error)
Delete(ctx context.Context, id string) error
UpdateStatus(ctx context.Context, id string, form *UpdateStatusForm) error
AdminResetPassword(ctx context.Context, actorID, targetID string) (*AdminResetPasswordResponse, error)
// Authentication operations
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error)
GetProfileWithCompanies(ctx context.Context, id string) (*CustomerResponse, error)
UpdateProfileKeywords(ctx context.Context, customerID string, keywords []string) (*CustomerResponse, error)
Logout(ctx context.Context, id string, accessToken string) error
// Forgot password operations
RequestResetPassword(ctx context.Context, form *RequestResetPasswordForm) (*RequestResetPasswordResponse, error)
VerifyOTP(ctx context.Context, form *VerifyOTPForm) (*VerifyOTPResponse, error)
ResetPassword(ctx context.Context, form *ResetPasswordForm) (*ResetPasswordResponse, error)
// Role assignment operations
AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error)
AssignCompanies(ctx context.Context, customerID string, Companies []string) error
}
// customerService implements the Service interface
type customerService struct {
repository Repository
logger logger.Logger
authService authorization.AuthorizationService
companyService company.Service
redisClient redis.Client
notification notification.SDK
validator ValidationService
auditLogger audit.Logger
}
// New creates a new customer service
func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, companyService company.Service, redisClient redis.Client, notificationSDK notification.SDK, validator ValidationService, auditLogger audit.Logger) Service {
return &customerService{
repository: repository,
logger: logger,
authService: authService,
companyService: companyService,
redisClient: redisClient,
notification: notificationSDK,
validator: validator,
auditLogger: auditLogger,
}
}
// Register creates a new customer
func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm) (*CustomerResponse, error) {
// Check if email already exists
existingCustomer, _ := s.repository.GetByEmail(ctx, form.Email)
if existingCustomer != nil {
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")
}
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")
}
var Companies []string
if len(form.Companies) > 0 {
validated, err := s.validateCompanyIDs(ctx, form.Companies)
if err != nil {
return nil, err
}
Companies = validated
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash password", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to process password")
}
// Create customer
customer := &Customer{
Type: CustomerType(form.Type),
Role: CustomerRole(form.Role),
Status: CustomerStatusActive,
Companies: Companies,
FullName: form.FullName,
Username: strings.ToLower(form.Username),
Email: form.Email,
Password: string(hashedPassword),
Phone: form.Phone,
}
// Keyword auto-generation is handled by the AI service when integrated; backend leaves keywords empty on register.
customer.KeywordsStatus = KeywordsStatusInProgress
keywords, err := s.generateKeywords(ctx, customer)
if err != nil {
s.logger.Warn("Failed to generate keywords, continuing without keywords", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
keywords = []string{}
customer.KeywordsStatus = KeywordsStatusFailed
} else {
customer.Keywords = keywords
customer.KeywordsStatus = KeywordsStatusReady
}
// 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,
"username": form.Username,
})
return nil, err
}
companies, err := s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies for customer", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
}
s.logger.Info("Customer created successfully", map[string]interface{}{
"customer_id": customer.ID,
"email": customer.Email,
"type": customer.Type,
"company_ids": Companies,
})
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: customer.Email,
Title: "Welcome to Opplens",
Message: fmt.Sprintf("Welcome to Opplens\nUsername: %s\nPassword: %s", strings.ToLower(customer.Username), form.Password),
Type: "info",
Priority: "important",
UserID: customer.ID.Hex(),
})
return customer.ToResponse(companies), nil
}
// GetByID retrieves a customer by ID
func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResponse, error) {
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
if err.Error() == "customer not found" {
return nil, errors.New("customer not found")
}
s.logger.Error("Failed to get customer by ID", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return nil, err
}
var companies []*CompanySummary
if len(customer.Companies) > 0 {
companies, err = s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies for customer", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
}
}
s.logger.Info("Customer retrieved successfully", map[string]interface{}{
"customer_id": customer.ID,
"email": customer.Email,
})
return customer.ToResponse(companies), nil
}
// Search searches for customers
func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*CustomerListResponse, error) {
page, err := s.repository.Search(ctx, form, pagination)
if err != nil {
s.logger.Error("Failed to search customers", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
var customerResponses []*CustomerResponse
for _, customer := range page.Items {
companies, err := s.loadCompaniesForCustomer(ctx, &customer)
if err != nil {
s.logger.Error("Failed to load companies for customer", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
}
customerResponses = append(customerResponses, customer.ToResponse(companies))
}
return &CustomerListResponse{
Customers: customerResponses,
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil
}
func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) {
page, err := s.repository.Search(ctx, form, pagination)
if err != nil {
s.logger.Error("Failed to search customers", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to search customers")
}
var customerResponses []*CustomerNotificationResponse
for _, customer := range page.Items {
customerResponses = append(customerResponses, customer.ToNotificationResponse())
}
return customerResponses, nil
}
// GetCustomersByIDs retrieves customers by IDs
func (s *customerService) GetCustomersByIDs(ctx context.Context, ids []string) ([]*CustomerNotificationResponse, error) {
customers, err := s.repository.GetByIDs(ctx, ids)
if err != nil {
return nil, err
}
var customerResponses []*CustomerNotificationResponse
for _, customer := range customers {
customerResponses = append(customerResponses, customer.ToNotificationResponse())
}
return customerResponses, nil
}
// GetCustomersByRole retrieves customers by role
func (s *customerService) GetCustomersByRole(ctx context.Context, role string) ([]*CustomerNotificationResponse, error) {
customers, err := s.repository.GetByRole(ctx, CustomerRole(role))
if err != nil {
s.logger.Error("Failed to get customers by role", map[string]interface{}{
"error": err.Error(),
"role": role,
})
return nil, err
}
var customerResponses []*CustomerNotificationResponse
for _, customer := range customers {
customerResponses = append(customerResponses, customer.ToNotificationResponse())
}
return customerResponses, nil
}
// GetCustomersByCompanies retrieves customers by companies
func (s *customerService) GetCustomersByCompanies(ctx context.Context, companies []string) ([]*CustomerNotificationResponse, error) {
customers, err := s.repository.GetByCompanies(ctx, companies)
if err != nil {
s.logger.Error("Failed to get customers by companies", map[string]interface{}{
"error": err.Error(),
"companies": companies,
})
return nil, err
}
var customerResponses []*CustomerNotificationResponse
for _, customer := range customers {
customerResponses = append(customerResponses, customer.ToNotificationResponse())
}
return customerResponses, nil
}
// Update updates a customer
func (s *customerService) Update(ctx context.Context, id string, form *UpdateCustomerForm) (*CustomerResponse, error) {
// Get existing customer
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get customer for update", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return nil, errors.New("customer not found")
}
// Check if email already exists (if changing email)
if form.Email != "" && form.Email != customer.Email {
existingCustomer, _ := s.repository.GetByEmail(ctx, form.Email)
if existingCustomer != nil {
return nil, errors.New("customer with this email already exists")
}
customer.Email = form.Email
}
// Check validator
if !s.validator.IsValidUsername(form.Username) {
return nil, errors.New("invalid username format")
}
changeUsername := false
// Check if username already exists (if changing username)
if form.Username != "" && strings.ToLower(form.Username) != customer.Username {
existingCustomer, _ := s.repository.GetByUsername(ctx, strings.ToLower(form.Username))
if existingCustomer != nil {
return nil, errors.New("customer with this username already exists")
}
customer.Username = strings.ToLower(form.Username)
changeUsername = true
}
companiesChanged := false
if form.Companies != nil {
companyIDs, validateErr := s.validateCompanyIDs(ctx, *form.Companies)
if validateErr != nil {
return nil, validateErr
}
companiesChanged = !companyIDsEqual(customer.Companies, companyIDs)
customer.Companies = companyIDs
}
infoChanged := false
if form.Type != "" && customer.Type != CustomerType(form.Type) {
customer.Type = CustomerType(form.Type)
infoChanged = true
}
if form.FullName != nil && (customer.FullName == nil || *form.FullName != *customer.FullName) {
customer.FullName = form.FullName
infoChanged = true
}
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
}
// Update role if provided
if form.Role != "" && customer.Role != CustomerRole(form.Role) {
customer.Role = CustomerRole(form.Role)
infoChanged = true
}
// Handle keywords
if form.Keywords != nil {
// Keywords explicitly provided by customer, set status to in_progress
// Customer's manual keywords are saved, but we'll enhance them with AI
customer.Keywords = form.Keywords
customer.KeywordsStatus = KeywordsStatusInProgress
// Save customer with in_progress status first, then process asynchronously
s.processKeywordsAsync(customer.ID.Hex())
} else if infoChanged || companiesChanged {
// Customer info changed, regenerate keywords
customer.KeywordsStatus = KeywordsStatusInProgress
keywords, err := s.generateKeywords(ctx, customer)
if err != nil {
s.logger.Warn("Failed to regenerate keywords, keeping existing keywords", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
customer.KeywordsStatus = KeywordsStatusFailed
} else {
customer.Keywords = keywords
customer.KeywordsStatus = KeywordsStatusReady
}
}
// 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,
})
return nil, errors.New("failed to update customer")
}
if changeUsername {
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: customer.Email,
Title: "Username Updated",
Message: fmt.Sprintf("Your username has been updated to %s", customer.Username),
Type: "info",
Priority: "important",
UserID: customer.ID.Hex(),
})
}
s.logger.Info("Customer updated successfully", map[string]interface{}{
"customer_id": customer.ID,
"email": customer.Email,
})
responseCompanies, err := s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies for customer response", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
responseCompanies = []*CompanySummary{}
}
return customer.ToResponse(responseCompanies), nil
}
// Delete deletes a customer (soft delete)
func (s *customerService) Delete(ctx context.Context, id string) error {
// Get customer to check if exists
_, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get customer for delete", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return errors.New("customer not found")
}
// Delete customer (soft delete)
err = s.repository.Delete(ctx, id)
if err != nil {
s.logger.Error("Failed to delete customer", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return errors.New("failed to delete customer")
}
s.logger.Info("Customer deleted successfully", map[string]interface{}{
"customer_id": id,
})
return nil
}
// UpdateStatus updates customer status
func (s *customerService) UpdateStatus(ctx context.Context, id string, form *UpdateStatusForm) error {
// Get customer to check if exists
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("customer not found")
}
// Update status
status := CustomerStatus(form.Status)
err = s.repository.UpdateStatus(ctx, id, status)
if err != nil {
s.logger.Error("Failed to update customer status", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
"status": form.Status,
})
return errors.New("failed to update customer status")
}
s.logger.Info("Customer status updated successfully", map[string]interface{}{
"customer_id": id,
"status": form.Status,
})
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: customer.Email,
Title: "Account Status Updated",
Message: fmt.Sprintf("Your account has been %s", strings.ToUpper(form.Status)),
Type: "warning",
Priority: "important",
UserID: customer.ID.Hex(),
})
return nil
}
// AssignCompanies assigns companies to a customer
func (s *customerService) AssignCompanies(ctx context.Context, customerID string, Companies []string) error {
// Get customer to check if exists
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
return errors.New("customer not found")
}
// Verify companies exist and update customer's company IDs
var validCompanies []string
for _, companyID := range Companies {
_, err = s.companyService.GetByID(ctx, companyID)
if err != nil {
s.logger.Error("Company not found during assignment", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
"company_id": companyID,
})
continue // Skip invalid company IDs
}
validCompanies = append(validCompanies, companyID)
}
// Update customer's company IDs
customer.Companies = make([]string, 0)
customer.Companies = append(customer.Companies, validCompanies...)
customer.UpdatedAt = time.Now().Unix()
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update customer with company assignments", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
return errors.New("failed to assign companies to customer")
}
s.logger.Info("Companies assigned to customer successfully", map[string]interface{}{
"customer_id": customerID,
"company_ids": validCompanies,
})
return nil
}
// RemoveCompanies removes companies from a customer
func (s *customerService) RemoveCompanies(ctx context.Context, customerID string, Companies []string) error {
// Get customer to check if exists
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
return errors.New("customer not found")
}
// Remove company IDs from customer's list
var updatedCompanies []string
for _, existingID := range customer.Companies {
shouldRemove := false
for _, removeID := range Companies {
if existingID == removeID {
shouldRemove = true
break
}
}
if !shouldRemove {
updatedCompanies = append(updatedCompanies, existingID)
}
}
// Update customer's company IDs
customer.Companies = updatedCompanies
customer.UpdatedAt = time.Now().Unix()
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update customer after removing companies", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
return errors.New("failed to remove companies from customer")
}
s.logger.Info("Companies removed from customer successfully", map[string]interface{}{
"customer_id": customerID,
"company_ids": Companies,
})
return nil
}
// Suspend suspends a customer
func (s *customerService) Suspend(ctx context.Context, id string, reason string) error {
// Get customer to check if exists
_, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("customer not found")
}
// Update status to suspended
err = s.repository.UpdateStatus(ctx, id, CustomerStatusSuspended)
if err != nil {
s.logger.Error("Failed to suspend customer", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return errors.New("failed to suspend customer")
}
s.logger.Info("Customer suspended successfully", map[string]interface{}{
"customer_id": id,
"reason": reason,
})
return nil
}
// Activate activates a customer
func (s *customerService) Activate(ctx context.Context, id string) error {
// Get customer to check if exists
_, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("customer not found")
}
// Update status to active
err = s.repository.UpdateStatus(ctx, id, CustomerStatusActive)
if err != nil {
s.logger.Error("Failed to activate customer", map[string]interface{}{
"error": err.Error(),
"customer_id": id,
})
return errors.New("failed to activate customer")
}
s.logger.Info("Customer activated successfully", map[string]interface{}{
"customer_id": id,
})
return nil
}
// Login handles customer authentication
func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) {
s.logger.Info("Customer login attempt", map[string]interface{}{
"username": strings.ToLower(form.Username),
})
// Get customer by username
customer, err := s.repository.GetByUsername(ctx, strings.ToLower(form.Username))
if err != nil {
s.logger.Warn("Customer login failed - username not found", map[string]interface{}{
"username": strings.ToLower(form.Username),
})
return nil, errors.New("invalid credentials")
}
// Check if customer is active
if customer.Status != CustomerStatusActive {
s.logger.Warn("Customer login failed - account not active", map[string]interface{}{
"username": strings.ToLower(form.Username),
"customer_id": customer.ID,
"status": string(customer.Status),
})
return nil, errors.New("account is not active")
}
// Verify password
err = bcrypt.CompareHashAndPassword([]byte(customer.Password), []byte(form.Password))
if err != nil {
s.logger.Warn("Customer login failed - wrong password", map[string]interface{}{
"customer_id": customer.ID,
"email": customer.Email,
})
return nil, errors.New("invalid credentials")
}
// Generate JWT tokens using authorization service
companyID := ""
if len(customer.Companies) > 0 {
companyID = customer.Companies[0] // Use first company ID for JWT token
}
tokenPair, err := s.authService.GenerateTokenPair(
customer.ID.Hex(),
customer.Email,
string(customer.Role), // Use customer's actual role
companyID,
)
if err != nil {
s.logger.Error("Failed to generate JWT tokens", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
return nil, errors.New("failed to generate authentication tokens")
}
err = s.repository.Login(ctx, customer.ID.Hex(), form.DeviceToken)
if err != nil {
s.logger.Error("Failed to update customer last login", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
}
companies, err := s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies for customer", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
}
s.logger.Info("Customer login successful", map[string]interface{}{
"username": form.Username,
"customer_id": customer.ID,
"customer_type": string(customer.Type),
})
return &AuthResponse{
Customer: customer.ToResponse(companies),
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now
}, nil
}
// RefreshToken handles token refresh for customers
func (s *customerService) RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error) {
s.logger.Info("Customer token refresh attempt", map[string]interface{}{
"refresh_token": refreshToken[:10] + "...", // Log only first 10 chars for security
})
// Validate refresh token and generate new access token
newAccessToken, err := s.authService.RefreshAccessToken(refreshToken)
if err != nil {
s.logger.Warn("Failed to refresh access token", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("invalid or expired refresh token")
}
// Parse the new access token to get customer information
validationResult, err := s.authService.ValidateAccessToken(newAccessToken)
if err != nil {
s.logger.Error("Failed to validate new access token", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to generate valid access token")
}
if !validationResult.Valid {
s.logger.Error("New access token validation failed", map[string]interface{}{
"error": validationResult.Error,
})
return nil, errors.New("failed to generate valid access token")
}
// Get customer information
customerID, err := bson.ObjectIDFromHex(validationResult.Claims.UserID)
if err != nil {
s.logger.Error("Failed to parse customer ID from token", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("invalid token format")
}
customer, err := s.repository.GetByID(ctx, customerID.Hex())
if err != nil {
s.logger.Error("Failed to get customer for token refresh", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.Hex(),
})
return nil, errors.New("customer not found")
}
// Check if customer is still active
if customer.Status != CustomerStatusActive {
return nil, errors.New("customer account is inactive")
}
companies, err := s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies for customer", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
}
s.logger.Info("Access token refreshed successfully", map[string]interface{}{
"customer_id": customer.ID,
"customer_type": string(customer.Type),
})
return &AuthResponse{
Customer: customer.ToResponse(companies),
AccessToken: newAccessToken,
RefreshToken: refreshToken, // Return the same refresh token
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now
}, nil
}
// GetProfileWithCompanies retrieves customer profile information including companies
func (s *customerService) GetProfileWithCompanies(ctx context.Context, customerID string) (*CustomerResponse, error) {
s.logger.Info("Getting customer profile with companies", map[string]interface{}{
"customer_id": customerID,
})
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("Failed to get customer profile with companies", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
return nil, errors.New("customer not found")
}
// Load companies for the customer
companies, err := s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies for customer profile", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
// Continue without companies if loading fails
companies = []*CompanySummary{}
}
customerResponse := customer.ToResponse(companies)
s.logger.Info("Customer profile retrieved with companies successfully", map[string]interface{}{
"customer_id": customerID,
"customer_type": string(customer.Type),
})
return customerResponse, nil
}
// UpdateProfileKeywords updates customer profile keywords and triggers AI enhancement asynchronously.
func (s *customerService) UpdateProfileKeywords(ctx context.Context, customerID string, keywords []string) (*CustomerResponse, error) {
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("Failed to get customer for profile keyword update", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
return nil, errors.New("customer not found")
}
customer.Keywords = keywords
customer.KeywordsStatus = KeywordsStatusInProgress
if err := s.repository.Update(ctx, customer); err != nil {
s.logger.Error("Failed to save profile keywords", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
return nil, errors.New("failed to update profile keywords")
}
s.processKeywordsAsync(customerID)
companies, err := s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies after profile keyword update", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
companies = []*CompanySummary{}
}
return customer.ToResponse(companies), nil
}
// Logout handles customer logout
func (s *customerService) Logout(ctx context.Context, customerID string, accessToken string) error {
s.logger.Info("Customer logout", map[string]interface{}{
"customer_id": customerID,
})
// get customer by id
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("Failed to get customer for logout", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
}
// Invalidate the access token
err = s.authService.InvalidateToken(accessToken)
if err != nil {
s.logger.Error("Failed to invalidate access token", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
// Don't return error here as the customer should still be logged out
return errors.New("failed to invalidate access token")
}
// delete device token from customer
customer.DeviceToken = make([]string, 0)
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update customer device token", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
}
s.logger.Info("Customer logout successful", map[string]interface{}{
"customer_id": customerID,
})
return nil
}
// RequestResetPassword handles password reset request
func (s *customerService) RequestResetPassword(ctx context.Context, form *RequestResetPasswordForm) (*RequestResetPasswordResponse, error) {
s.logger.Info("Password reset request", map[string]interface{}{
"email": form.Email,
})
// Check if customer exists
customer, err := s.repository.GetByEmail(ctx, form.Email)
if err != nil {
s.logger.Warn("Password reset request for non-existent email", map[string]interface{}{
"email": form.Email,
})
// Return success even if email doesn't exist for security
return &RequestResetPasswordResponse{
Message: "If the email exists, a password reset code has been sent",
Success: true,
}, nil
}
// Check if customer is active
if customer.Status != CustomerStatusActive {
s.logger.Warn("Password reset request for inactive customer", map[string]interface{}{
"email": form.Email,
"customer_id": customer.ID,
"status": string(customer.Status),
})
return &RequestResetPasswordResponse{
Message: "If the email exists, a password reset code has been sent",
Success: true,
}, nil
}
// Generate 6-digit OTP
otpCode, err := s.generateOTP()
if err != nil {
s.logger.Error("Failed to generate OTP", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
return nil, errors.New("failed to generate verification code")
}
// Store OTP in Redis with 10 minutes expiry
otpKey := fmt.Sprintf("otp:%s", form.Email)
otpData := OTP{
Code: otpCode,
Email: form.Email,
ExpiresAt: time.Now().Add(10 * time.Minute).Unix(),
CreatedAt: time.Now().Unix(),
}
otpDataJSON, err := json.Marshal(otpData)
if err != nil {
s.logger.Error("Failed to marshal OTP data", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
return nil, errors.New("failed to process reset request")
}
err = s.redisClient.Set(ctx, otpKey, string(otpDataJSON), 10*time.Minute)
if err != nil {
s.logger.Error("Failed to store OTP in Redis", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
return nil, errors.New("failed to process reset request")
}
// Send OTP via email
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: form.Email,
Title: "Password Reset",
Message: fmt.Sprintf("Your password reset code is: %s", otpCode),
Type: "info",
Priority: "important",
UserID: customer.ID.Hex(),
})
s.logger.Info("Password reset OTP sent successfully", map[string]interface{}{
"email": form.Email,
"customer_id": customer.ID,
})
return &RequestResetPasswordResponse{
Message: "Password reset code sent to your email",
Success: true,
}, nil
}
// VerifyOTP handles OTP verification
func (s *customerService) VerifyOTP(ctx context.Context, form *VerifyOTPForm) (*VerifyOTPResponse, error) {
s.logger.Info("OTP verification attempt", map[string]interface{}{
"email": form.Email,
})
// Get OTP from Redis
otpKey := fmt.Sprintf("otp:%s", form.Email)
otpDataStr, err := s.redisClient.Get(ctx, otpKey)
if err != nil {
s.logger.Warn("OTP verification failed - OTP not found or expired", map[string]interface{}{
"email": form.Email,
})
return nil, errors.New("invalid or expired verification code")
}
var otpData OTP
err = json.Unmarshal([]byte(otpDataStr), &otpData)
if err != nil {
s.logger.Error("Failed to unmarshal OTP data", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
return nil, errors.New("invalid verification code format")
}
if otpData.Code != form.Code {
s.logger.Warn("OTP verification failed - wrong code", map[string]interface{}{
"email": form.Email,
})
return nil, errors.New("invalid verification code")
}
// Generate reset token
resetToken, err := s.generateResetToken()
if err != nil {
s.logger.Error("Failed to generate reset token", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
return nil, errors.New("failed to generate reset token")
}
// Store reset token in Redis with 30 minutes expiry
tokenKey := fmt.Sprintf("reset_token:%s", resetToken)
tokenData := ResetToken{
Token: resetToken,
Email: form.Email,
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
CreatedAt: time.Now().Unix(),
}
tokenDataJSON, err := json.Marshal(tokenData)
if err != nil {
s.logger.Error("Failed to marshal reset token data", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
return nil, errors.New("failed to process verification")
}
err = s.redisClient.Set(ctx, tokenKey, string(tokenDataJSON), 30*time.Minute)
if err != nil {
s.logger.Error("Failed to store reset token in Redis", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
return nil, errors.New("failed to process verification")
}
// Delete OTP from Redis after successful verification
err = s.redisClient.Del(ctx, otpKey)
if err != nil {
s.logger.Warn("Failed to delete OTP after verification", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
})
}
s.logger.Info("OTP verified successfully", map[string]interface{}{
"email": form.Email,
})
return &VerifyOTPResponse{
Message: "OTP verified successfully",
Token: resetToken,
Success: true,
}, nil
}
// ResetPassword handles password reset
func (s *customerService) ResetPassword(ctx context.Context, form *ResetPasswordForm) (*ResetPasswordResponse, error) {
s.logger.Info("Password reset attempt", map[string]interface{}{
"token": form.Token,
})
// Validate password strength
if !s.isValidPassword(form.NewPassword) {
return nil, errors.New("password must be at least 5 characters with uppercase, lowercase, and special character")
}
// Get reset token from Redis
tokenKey := fmt.Sprintf("reset_token:%s", form.Token)
tokenDataStr, err := s.redisClient.Get(ctx, tokenKey)
if err != nil {
s.logger.Warn("Password reset failed - invalid or expired token", map[string]interface{}{
"token": form.Token,
})
return nil, errors.New("invalid or expired reset token")
}
var tokenData ResetToken
err = json.Unmarshal([]byte(tokenDataStr), &tokenData)
if err != nil {
s.logger.Error("Failed to unmarshal reset token data", map[string]interface{}{
"error": err.Error(),
"token": form.Token,
})
return nil, errors.New("invalid reset token format")
}
if tokenData.Token != form.Token {
s.logger.Warn("Password reset failed - wrong token", map[string]interface{}{
"token": form.Token,
})
return nil, errors.New("invalid reset token")
}
// Get customer
customer, err := s.repository.GetByEmail(ctx, tokenData.Email)
if err != nil {
s.logger.Error("Password reset failed - customer not found", map[string]interface{}{
"error": err.Error(),
"email": tokenData.Email,
})
return nil, errors.New("customer not found")
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash new password", map[string]interface{}{
"error": err.Error(),
"email": tokenData.Email,
})
return nil, errors.New("failed to process new password")
}
// Update customer password
customer.Password = string(hashedPassword)
customer.UpdatedAt = time.Now().Unix()
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update customer password", map[string]interface{}{
"error": err.Error(),
"email": tokenData.Email,
"customer_id": customer.ID,
})
return nil, errors.New("failed to reset password")
}
// Delete reset token from Redis
err = s.redisClient.Del(ctx, tokenKey)
if err != nil {
s.logger.Warn("Failed to delete reset token after password reset", map[string]interface{}{
"error": err.Error(),
"email": tokenData.Email,
})
}
s.logger.Info("Password reset successfully", map[string]interface{}{
"email": tokenData.Email,
"customer_id": customer.ID,
})
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: tokenData.Email,
Title: "Password Reset Success",
Message: "Your password has been reset successfully",
Type: "info",
Priority: "important",
UserID: customer.ID.Hex(),
})
return &ResetPasswordResponse{
Message: "Password reset successfully",
Success: true,
}, nil
}
// generateOTP generates a 6-digit OTP
func (s *customerService) generateOTP() (string, error) {
// Generate 6 random digits
otp := ""
for i := 0; i < 6; i++ {
num, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
otp += num.String()
}
return otp, nil
}
// generateResetToken generates a random reset token
func (s *customerService) generateResetToken() (string, error) {
// Generate 32 random bytes
tokenBytes := make([]byte, 32)
_, err := rand.Read(tokenBytes)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", tokenBytes), nil
}
// isValidPassword validates password strength
func (s *customerService) isValidPassword(password string) bool {
if len(password) < 5 {
return false
}
hasUpper := false
hasLower := false
hasSpecial := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
} else if char >= 'a' && char <= 'z' {
hasLower = true
} else if char == '!' || char == '@' || char == '#' || char == '$' || char == '%' || char == '^' || char == '&' || char == '*' {
hasSpecial = true
}
}
return hasUpper && hasLower && hasSpecial
}
func (s *customerService) validateCompanyIDs(ctx context.Context, companyIDs []string) ([]string, error) {
if len(companyIDs) == 0 {
return []string{}, nil
}
validated := make([]string, 0, len(companyIDs))
seen := make(map[string]struct{}, len(companyIDs))
for _, companyID := range companyIDs {
if companyID == "" {
continue
}
if _, exists := seen[companyID]; exists {
continue
}
if _, err := bson.ObjectIDFromHex(companyID); err != nil {
return nil, errors.New("invalid company ID format: " + companyID)
}
if _, err := s.companyService.GetByID(ctx, companyID); err != nil {
s.logger.Error("Invalid company ID provided", map[string]interface{}{
"error": err.Error(),
"company_id": companyID,
})
return nil, errors.New("invalid company ID: " + companyID)
}
seen[companyID] = struct{}{}
validated = append(validated, companyID)
}
return validated, nil
}
func companyIDsEqual(existing, updated []string) bool {
if len(existing) != len(updated) {
return false
}
counts := make(map[string]int, len(existing))
for _, id := range existing {
counts[id]++
}
for _, id := range updated {
counts[id]--
if counts[id] < 0 {
return false
}
}
for _, count := range counts {
if count != 0 {
return false
}
}
return true
}
// loadCompaniesForCustomer loads company summaries for a customer
func (s *customerService) loadCompaniesForCustomer(ctx context.Context, customer *Customer) ([]*CompanySummary, error) {
if len(customer.Companies) == 0 {
return []*CompanySummary{}, nil
}
// Load companies in batch using the new GetCompaniesByIDs method
companies, err := s.companyService.GetByIDs(ctx, customer.Companies)
if err != nil {
s.logger.Error("Failed to load companies for customer", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
"company_ids": customer.Companies,
})
return []*CompanySummary{}, err
}
// Convert to CompanySummary format
var companySummaries []*CompanySummary
for _, company := range companies {
companySummaries = append(companySummaries, &CompanySummary{
ID: company.ID,
Name: company.Name,
})
}
s.logger.Info("Companies loaded for customer", map[string]interface{}{
"customer_id": customer.ID,
"requested_count": len(customer.Companies),
"loaded_count": len(companySummaries),
})
return companySummaries, nil
}
// AssignRole assigns a role to a customer
func (s *customerService) AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error) {
s.logger.Info("Assigning role to customer", map[string]interface{}{
"customer_id": customerID,
"role": form.Role,
})
// Get customer to check if exists
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("Failed to get customer for role assignment", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
return nil, errors.New("customer not found")
}
// Validate role
role := CustomerRole(form.Role)
if role != CustomerRoleAdmin && role != CustomerRoleAnalyst {
s.logger.Error("Invalid role provided", map[string]interface{}{
"customer_id": customerID,
"role": form.Role,
})
return nil, errors.New("invalid role. Must be 'admin' or 'analyst'")
}
// Update customer role
customer.Role = role
customer.UpdatedAt = time.Now().Unix()
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update customer role", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
"role": form.Role,
})
return nil, errors.New("failed to assign role to customer")
}
s.logger.Info("Role assigned to customer successfully", map[string]interface{}{
"customer_id": customerID,
"role": form.Role,
})
return &AssignRoleResponse{
Message: "Role assigned successfully",
Success: true,
}, nil
}
// generateKeywords is reserved for future integration with the AI service; the backend does not generate keywords.
func (s *customerService) generateKeywords(ctx context.Context, customer *Customer) ([]string, error) {
_ = ctx
_ = customer
return []string{}, nil
}
func (s *customerService) processKeywordsAsync(customerID string) {
go func() {
ctx := context.Background()
cust, err := s.repository.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("Failed to get customer for keyword processing", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
return
}
keywords, err := s.generateKeywords(ctx, cust)
if err != nil {
s.logger.Warn("Failed to process keywords after manual update", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID,
})
cust.KeywordsStatus = KeywordsStatusFailed
} else if len(keywords) > 0 {
cust.Keywords = keywords
cust.KeywordsStatus = KeywordsStatusReady
} else {
s.logger.Debug("No auto-generated keywords from backend, keeping existing keywords", map[string]interface{}{
"customer_id": customerID,
})
cust.KeywordsStatus = KeywordsStatusReady
}
if updateErr := s.repository.Update(ctx, cust); updateErr != nil {
s.logger.Error("Failed to update customer keywords status", map[string]interface{}{
"error": updateErr.Error(),
"customer_id": customerID,
})
} else {
s.logger.Info("Keywords processed successfully", map[string]interface{}{
"customer_id": customerID,
"status": cust.KeywordsStatus,
})
}
}()
}