package service import ( "context" "encoding/json" "errors" "fmt" "net/mail" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" "gorm.io/gorm" ) // AgentService implements business logic for Agent CRUD operations. // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb // An "agent" in Chatwoot is a User with an AccountUser association in a specific account. type AgentService struct { agentRepo *repository.AgentRepo db *gorm.DB confirmationMailer ProfileConfirmationMailer } var ErrAgentNameBlank = errors.New("agent name cannot be blank") // NewAgentService creates a new Agent service. func NewAgentService(agentRepo *repository.AgentRepo, db *gorm.DB) *AgentService { return &AgentService{agentRepo: agentRepo, db: db} } func (s *AgentService) SetConfirmationMailer(mailer ProfileConfirmationMailer) { s.confirmationMailer = mailer } func (s *AgentService) DB() *gorm.DB { if s == nil { return nil } return s.db } // CreateAgentRequest is the DTO for creating/adding an agent to an account. // Reference: Chatwoot agents_controller.rb#create → new_agent_params (email, name, role, availability, auto_offline) type CreateAgentRequest struct { Email string `json:"email" validate:"required,email"` Name string `json:"name" validate:"omitempty,min=1"` Role string `json:"role" validate:"omitempty,oneof=agent administrator"` Availability string `json:"availability" validate:"omitempty,oneof=online offline busy"` AutoOffline bool `json:"auto_offline"` CustomRoleID *uint `json:"custom_role_id,omitempty"` } // UpdateAgentRequest is the DTO for updating an agent. // Reference: Chatwoot agents_controller.rb#update → agent_params (name on User, role/availability/auto_offline on AccountUser) type UpdateAgentRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=1"` Role string `json:"role,omitempty" validate:"omitempty,oneof=agent administrator"` Availability string `json:"availability,omitempty" validate:"omitempty,oneof=online offline busy"` AutoOffline bool `json:"auto_offline"` CustomRoleID *uint `json:"custom_role_id,omitempty"` nameSet bool autoOfflineSet bool customRoleSet bool } func (r *UpdateAgentRequest) UnmarshalJSON(data []byte) error { type alias UpdateAgentRequest var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } var decoded alias if err := json.Unmarshal(data, &decoded); err != nil { return err } *r = UpdateAgentRequest(decoded) _, r.nameSet = raw["name"] _, r.autoOfflineSet = raw["auto_offline"] _, r.customRoleSet = raw["custom_role_id"] return nil } func (r UpdateAgentRequest) NameSet() bool { return r.nameSet } func (r UpdateAgentRequest) AutoOfflineSet() bool { return r.autoOfflineSet } func (r UpdateAgentRequest) CustomRoleIDSet() bool { return r.customRoleSet } // BulkCreateAgentRequest is the DTO for bulk creating agents. // Reference: Chatwoot agents_controller.rb#bulk_create → params[:emails] type BulkCreateAgentRequest struct { Emails []string `json:"emails"` } // List retrieves all agents for an account with pagination. func (s *AgentService) List(ctx context.Context, accountID uint, offset, limit int) ([]repository.AgentDetail, int64, error) { return s.agentRepo.ListByAccount(ctx, accountID, offset, limit) } // Get retrieves a single agent by user ID scoped to an account. func (s *AgentService) Get(ctx context.Context, userID, accountID uint) (*repository.AgentDetail, error) { return s.agentRepo.FindAgentByID(ctx, userID, accountID) } // Create adds an agent (user) to an account. // Reference: Chatwoot AgentBuilder — finds or creates User, then creates AccountUser. func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uint, req CreateAgentRequest) (*repository.AgentDetail, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation: %w", err) } role := req.Role if role == "" { role = "agent" } availability := req.Availability if availability == "" { availability = "offline" } name := strings.TrimSpace(req.Name) if name == "" { name = agentNameFromEmail(req.Email) } customRoleID := uint(0) if req.CustomRoleID != nil { customRoleID = *req.CustomRoleID } detail, err := s.agentRepo.CreateAgent(ctx, accountID, inviterID, name, req.Email, role, availability, req.AutoOffline, customRoleID) if err != nil { if errors.Is(err, repository.ErrAlreadyMember) { return nil, repository.ErrAlreadyMember } applogger.L().Errorf("AgentService.Create: %v", err) return nil, err } if detail.IsNewUser { if err := s.sendAgentInvitationConfirmation(ctx, accountID, inviterID, detail); err != nil { return nil, err } } return detail, nil } func (s *AgentService) sendAgentInvitationConfirmation(ctx context.Context, accountID, inviterID uint, detail *repository.AgentDetail) error { if s == nil || s.db == nil || detail == nil || detail.ConfirmedAt != nil { return nil } resetPasswordToken, err := generateAuthToken() if err != nil { return fmt.Errorf("generate invitation reset token: %w", err) } now := time.Now().UTC() digestedResetToken := digestAuthToken(resetPasswordToken) if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{ "reset_password_token": digestedResetToken, "reset_password_sent_at": now, }).Error; err != nil { return fmt.Errorf("persist invitation reset token: %w", err) } detail.ResetPasswordToken = digestedResetToken detail.ResetPasswordSentAt = &now var account model.Account if err := s.db.WithContext(ctx).First(&account, accountID).Error; err != nil { return fmt.Errorf("load invitation account: %w", err) } var inviter *model.User if inviterID != 0 { var inviterUser model.User if err := s.db.WithContext(ctx).First(&inviterUser, inviterID).Error; err != nil { return fmt.Errorf("load invitation inviter: %w", err) } inviter = &inviterUser } if s.confirmationMailer == nil { applogger.L().Infof("confirmation mailer not configured for invited user %d (%s)", detail.ID, detail.Email) return nil } brandName := s.confirmationBrandName(ctx) req := buildProfileConfirmationMailRequest(&detail.User, &account, inviter, brandName, envConfirmationFrontendURL(), "", resetPasswordToken) return s.confirmationMailer.SendConfirmationInstructions(ctx, req) } func (s *AgentService) confirmationBrandName(ctx context.Context) string { if s == nil || s.db == nil { return "Chatwoot" } var cfg model.InstallationConfig if err := s.db.WithContext(ctx).Where("name = ?", "BRAND_NAME").First(&cfg).Error; err != nil { return "Chatwoot" } return cfg.Value } // Update modifies an agent's details (name on User, role/availability on AccountUser). func (s *AgentService) Update(ctx context.Context, userID, accountID uint, req UpdateAgentRequest) (*repository.AgentDetail, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation: %w", err) } if req.NameSet() && strings.TrimSpace(req.Name) == "" { return nil, ErrAgentNameBlank } return s.agentRepo.UpdateAgent(ctx, userID, accountID, req.Name, req.Role, req.Availability, req.AutoOffline, req.AutoOfflineSet(), req.CustomRoleID, req.CustomRoleIDSet()) } // Delete removes an agent from an account (deletes AccountUser, optionally deletes User). func (s *AgentService) Delete(ctx context.Context, userID, accountID uint) error { return s.agentRepo.DeleteAgent(ctx, userID, accountID) } // BulkCreate adds multiple agents to an account by email. // Reference: Chatwoot agents_controller.rb#bulk_create — iterates emails, creates AgentBuilder for each. // Silently skips emails that fail (duplicate, etc.). func (s *AgentService) BulkCreate(ctx context.Context, accountID uint, inviterID uint, req BulkCreateAgentRequest) ([]repository.AgentDetail, error) { emails := make([]string, 0, len(req.Emails)) for _, email := range req.Emails { if isValidBulkAgentEmail(email) { emails = append(emails, strings.TrimSpace(email)) } } created, err := s.agentRepo.BulkCreateAgents(ctx, accountID, inviterID, emails) if err != nil { return nil, err } if s.db != nil { if err := s.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", accountID).Update("onboarding_step", "").Error; err != nil { return created, fmt.Errorf("clear onboarding step: %w", err) } } return created, nil } func isValidBulkAgentEmail(email string) bool { email = strings.TrimSpace(email) if email == "" { return false } addr, err := mail.ParseAddress(email) return err == nil && addr.Address == email } func agentNameFromEmail(email string) string { email = strings.TrimSpace(email) if at := strings.Index(email, "@"); at > 0 { return email[:at] } return email } // AvailableAgentCount returns the number of additional agents that can be added to an account. // Reference: Chatwoot agents_controller.rb#available_agent_count — usage_limits[:agents] - agents.count // Returns -1 if no limit is set (0 = unlimited in our convention). func (s *AgentService) AvailableAgentCount(ctx context.Context, accountID uint) (int, error) { var account model.Account if err := s.db.WithContext(ctx).First(&account, accountID).Error; err != nil { return 0, fmt.Errorf("get account: %w", err) } currentCount, err := s.agentRepo.CountByAccount(ctx, accountID) if err != nil { return 0, fmt.Errorf("count agents: %w", err) } // AgentLimit=0 means unlimited (no restriction) if account.AgentLimit == 0 { return -1, nil } available := account.AgentLimit - int(currentCount) if available < 0 { available = 0 } return available, nil } // CanAddAgent checks whether an account can add at least one more agent. // Reference: Chatwoot agents_controller.rb#can_add_agent? — available_agent_count.positive? func (s *AgentService) CanAddAgent(ctx context.Context, accountID uint) (bool, error) { available, err := s.AvailableAgentCount(ctx, accountID) if err != nil { return false, err } // -1 means unlimited return available < 0 || available > 0, nil } // CanAddAgents checks whether an account can add N more agents. // Reference: Chatwoot agents_controller.rb#validate_limit_for_bulk_create func (s *AgentService) CanAddAgents(ctx context.Context, accountID uint, count int) (bool, error) { available, err := s.AvailableAgentCount(ctx, accountID) if err != nil { return false, err } // -1 means unlimited return available < 0 || available >= count, nil }