Files
tm_back/internal/customer/service.go
T
Mazyar 241e3b5f2d Implement audit logging functionality across services and middleware
- Introduced a new `auditlog` package to handle audit logging for user actions, including creation, updates, deletions, and authentication events.
- Enhanced existing services (customer, user) to log relevant actions using the new audit logger, capturing details such as actor ID, action type, target type, and success status.
- Added middleware to enrich request context with metadata for audit logging, ensuring comprehensive tracking of user interactions.
- Integrated Elasticsearch for persistent storage of audit logs, with fallback to file-only logging if Elasticsearch is unavailable.
- Updated API documentation to include new audit log endpoints for administrative access.

This update significantly improves the system's ability to track and audit user actions, enhancing security and accountability within the application.
2026-06-29 21:11:33 +03:30

1577 lines
48 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,
})
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionCustomerCreate,
TargetType: audit.TargetTypeCustomer,
TargetID: customer.ID.Hex(),
Success: true,
Metadata: map[string]string{"role": string(customer.Role)},
})
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" {
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionCustomerRead,
TargetType: audit.TargetTypeCustomer,
TargetID: id,
Success: false,
Metadata: map[string]string{"reason": "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,
})
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionCustomerRead,
TargetType: audit.TargetTypeCustomer,
TargetID: id,
Success: true,
})
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))
}
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionCustomerList,
Success: true,
Metadata: audit.MergeMetadata(
customerSearchFilterMetadata(form),
audit.PaginationMetadata(pagination.Limit, pagination.Offset, page.TotalCount),
),
})
return &CustomerListResponse{
Customers: customerResponses,
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil
}
func customerSearchFilterMetadata(form *SearchCustomersForm) map[string]string {
if form == nil {
return nil
}
return audit.MergeMetadata(
audit.FlagMetadata("has_search", form.Search != nil && *form.Search != ""),
audit.FlagMetadata("has_full_name", form.FullName != nil && *form.FullName != ""),
audit.FlagMetadata("has_email_filter", form.Email != nil && *form.Email != ""),
audit.OptionalStringMetadata("status", form.Status),
audit.OptionalStringMetadata("role", form.Role),
audit.OptionalStringMetadata("type", form.Type),
audit.OptionalStringMetadata("company_id", form.CompanyID),
)
}
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,
})
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionCustomerUpdate,
TargetType: audit.TargetTypeCustomer,
TargetID: id,
Success: true,
})
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,
})
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionCustomerDelete,
TargetType: audit.TargetTypeCustomer,
TargetID: id,
Success: true,
})
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,
})
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionCustomerStatusChange,
TargetType: audit.TargetTypeCustomer,
TargetID: id,
Success: true,
Metadata: map[string]string{"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),
})
s.auditLogger.Log(ctx, audit.Entry{
Action: audit.ActionAuthLoginFailure,
ActorType: audit.ActorTypeCustomer,
TargetType: audit.TargetTypeCustomer,
Success: false,
})
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),
})
s.auditLogger.Log(ctx, audit.Entry{
ActorID: customer.ID.Hex(),
ActorType: audit.ActorTypeCustomer,
TargetType: audit.TargetTypeCustomer,
TargetID: customer.ID.Hex(),
Action: audit.ActionAuthLoginFailure,
Success: false,
Metadata: map[string]string{"reason": "inactive"},
})
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,
})
s.auditLogger.Log(ctx, audit.Entry{
ActorID: customer.ID.Hex(),
ActorType: audit.ActorTypeCustomer,
TargetType: audit.TargetTypeCustomer,
TargetID: customer.ID.Hex(),
Action: audit.ActionAuthLoginFailure,
Success: false,
})
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),
})
s.auditLogger.Log(ctx, audit.Entry{
ActorID: customer.ID.Hex(),
ActorType: audit.ActorTypeCustomer,
TargetType: audit.TargetTypeCustomer,
TargetID: customer.ID.Hex(),
Action: audit.ActionAuthLoginSuccess,
Success: true,
Metadata: map[string]string{"role": string(customer.Role)},
})
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,
})
s.auditLogger.Log(ctx, audit.Entry{
ActorID: customerID,
ActorType: audit.ActorTypeCustomer,
TargetType: audit.TargetTypeCustomer,
TargetID: customerID,
Action: audit.ActionAuthLogout,
Success: true,
})
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,
})
}
}()
}