Files
gochat/backend/internal/service/agent_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

304 lines
11 KiB
Go

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
}