337 lines
11 KiB
Go
337 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
|
|
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
|
|
}
|
|
|
|
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) 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
|
|
}
|
|
temporaryPassword, err := generateTemporaryPassword()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate temporary password: %w", err)
|
|
}
|
|
passwordDigest, err := pkgcrypto.HashPassword(temporaryPassword)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash temporary password: %w", err)
|
|
}
|
|
confirmedAt := time.Now().UTC()
|
|
detail, err := s.agentRepo.CreateAgent(ctx, accountID, inviterID, name, req.Email, role, availability, req.AutoOffline, customRoleID, passwordDigest, confirmedAt)
|
|
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 {
|
|
detail.TemporaryPassword = temporaryPassword
|
|
}
|
|
|
|
return detail, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ResetPassword assigns a new one-time-visible random password to an email agent.
|
|
// The plaintext password is returned to the administrator and is never persisted.
|
|
func (s *AgentService) ResetPassword(ctx context.Context, userID, accountID uint) (string, error) {
|
|
detail, err := s.agentRepo.FindAgentByID(ctx, userID, accountID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if detail.Provider != "" && detail.Provider != "email" {
|
|
return "", fmt.Errorf("password reset is only allowed for email users")
|
|
}
|
|
|
|
temporaryPassword, err := generateTemporaryPassword()
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate temporary password: %w", err)
|
|
}
|
|
passwordDigest, err := pkgcrypto.HashPassword(temporaryPassword)
|
|
if err != nil {
|
|
return "", fmt.Errorf("hash temporary password: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
|
"password": passwordDigest,
|
|
"password_digest": passwordDigest,
|
|
"confirmed_at": now,
|
|
"confirmation_token": "",
|
|
"reset_password_token": "",
|
|
"reset_password_sent_at": nil,
|
|
}).Error; err != nil {
|
|
return "", err
|
|
}
|
|
return temporaryPassword, nil
|
|
}
|
|
|
|
// 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 := make([]repository.AgentDetail, 0, len(emails))
|
|
for _, email := range emails {
|
|
detail, err := s.Create(ctx, accountID, inviterID, CreateAgentRequest{Email: email})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
created = append(created, *detail)
|
|
}
|
|
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
|
|
}
|
|
|
|
const temporaryPasswordLength = 16
|
|
|
|
func generateTemporaryPassword() (string, error) {
|
|
groups := []string{
|
|
"ABCDEFGHJKLMNPQRSTUVWXYZ",
|
|
"abcdefghijkmnopqrstuvwxyz",
|
|
"23456789",
|
|
"!@#$%",
|
|
}
|
|
all := strings.Join(groups, "")
|
|
password := make([]byte, 0, temporaryPasswordLength)
|
|
for _, group := range groups {
|
|
char, err := randomPasswordCharacter(group)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
password = append(password, char)
|
|
}
|
|
for len(password) < temporaryPasswordLength {
|
|
char, err := randomPasswordCharacter(all)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
password = append(password, char)
|
|
}
|
|
for i := len(password) - 1; i > 0; i-- {
|
|
j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
password[i], password[j.Int64()] = password[j.Int64()], password[i]
|
|
}
|
|
return string(password), nil
|
|
}
|
|
|
|
func randomPasswordCharacter(alphabet string) (byte, error) {
|
|
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return alphabet[index.Int64()], 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
|
|
}
|