package autoassignment // AssignmentService provides the core auto-assignment logic. // // Reference: Chatwoot AutoAssignment pattern // - AssignmentService: bulk assigns conversations in an inbox. // Finds unassigned open conversations, filters by team and rate limit, // then uses RoundRobinSelector. // - AgentAssignmentService: assigns a specific conversation. // Intersects online agents with allowed agents, then round-robin. // // In gochat, we combine both into a single AssignmentService that handles: // - Bulk assignment of unassigned conversations in an inbox // - Single conversation assignment for a specific conversation // - Rate limiting per agent via RateLimiter import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) const assignmentBatchLimit = 100 // AssignmentService handles auto-assignment of conversations to agents. type AssignmentService struct { db *gorm.DB redis *redis.Client roundRobin *RoundRobinSelector lowestLoad *LowestLoadSelector rateLimiter *RateLimiter } // NewAssignmentService creates a new AssignmentService. func NewAssignmentService(db *gorm.DB, rdb *redis.Client) *AssignmentService { rr := NewRoundRobinSelector(rdb) ll := NewLowestLoadSelector(db) rl := NewRateLimiter(rdb) return &AssignmentService{ db: db, redis: rdb, roundRobin: rr, lowestLoad: ll, rateLimiter: rl, } } // AssignUnassignedConversations bulk assigns all unassigned open conversations // in the given inbox. // Reference: Chatwoot AssignmentService.assign_unassigned_conversations // // Steps: // 1. Find all unassigned open conversations for the inbox // 2. Get the inbox's assignment policy and rate limits // 3. Get the list of eligible agents (online + members of the inbox) // 4. For each conversation, select an agent via round-robin + rate limit // 5. Assign the conversation to the selected agent func (s *AssignmentService) AssignUnassignedConversations(ctx context.Context, inboxID uint, accountID uint) ([]uint, error) { // Step 1: Check if auto-assignment is enabled for this inbox inbox, err := s.getInbox(ctx, inboxID) if err != nil { return nil, fmt.Errorf("get inbox: %w", err) } if !inbox.EnableAutoAssignment { applogger.L().Infof("auto-assignment disabled for inbox %d", inboxID) return nil, nil } // Step 2: Load the policy associated with this inbox. policy, err := s.getInboxPolicy(ctx, accountID, inboxID) if err != nil { return nil, fmt.Errorf("get assignment policy: %w", err) } if policy != nil && !policy.Enabled { return nil, nil } advancedAssignment, err := s.advancedAssignmentEnabled(ctx, accountID) if err != nil { return nil, fmt.Errorf("get advanced assignment feature: %w", err) } // Step 3: Find unassigned open conversations in policy order. conversations, err := s.findUnassignedConversations(ctx, inboxID, accountID, policy) if err != nil { return nil, fmt.Errorf("find unassigned: %w", err) } if len(conversations) == 0 { return nil, nil } // Step 4: Resolve selector and rate limits from the linked policy. selector := EffectivePolicy(policy, advancedAssignment) limit := EffectiveLimit(policy) window := EffectiveWindow(policy) // Step 5: Assign each conversation using its team-scoped candidates. assignedIDs := make([]uint, 0) for _, conv := range conversations { agents, err := s.getEligibleAgents(ctx, inboxID, accountID, conv.TeamID, advancedAssignment) if err != nil { return assignedIDs, fmt.Errorf("get eligible agents for conversation %d: %w", conv.ID, err) } if advancedAssignment { agents, err = s.filterAgentsByCapacityExclusions(ctx, accountID, &conv, agents) if err != nil { return assignedIDs, fmt.Errorf("apply capacity exclusions for conversation %d: %w", conv.ID, err) } } if len(agents) == 0 { continue } s.roundRobin.SyncQueue(ctx, inboxID, agents) agentID, err := s.selectAgent(ctx, inboxID, agents, selector, limit, window) if err != nil { applogger.L().Warnf("failed to select agent for conversation %d: %v", conv.ID, err) continue } if agentID == 0 { // All agents hit rate limit, skip continue } assigned, err := s.assignConversation(ctx, accountID, inboxID, conv.ID, agentID) if err != nil { applogger.L().Warnf("failed to assign conversation %d to agent %d: %v", conv.ID, agentID, err) continue } if !assigned { continue } // Track rate limit if err := s.rateLimiter.Increment(ctx, inboxID, agentID, window); err != nil { applogger.L().Errorf("failed to track assignment rate for inbox %d agent %d: %v", inboxID, agentID, err) } assignedIDs = append(assignedIDs, conv.ID) } applogger.L().Infof("auto-assigned %d conversations in inbox %d", len(assignedIDs), inboxID) return assignedIDs, nil } // AssignConversation assigns a specific conversation to an agent. // Reference: Chatwoot AgentAssignmentService.assign_conversation // // Steps: // 1. Get eligible agents for the conversation's inbox // 2. Select an agent via round-robin + rate limit // 3. Assign the conversation func (s *AssignmentService) AssignConversation(ctx context.Context, conversationID uint, inboxID uint, accountID uint) (uint, error) { // Check if auto-assignment is enabled inbox, err := s.getInbox(ctx, inboxID) if err != nil { return 0, fmt.Errorf("get inbox: %w", err) } if !inbox.EnableAutoAssignment { return 0, nil } policy, err := s.getInboxPolicy(ctx, accountID, inboxID) if err != nil { return 0, fmt.Errorf("get assignment policy: %w", err) } if policy != nil && !policy.Enabled { return 0, nil } advancedAssignment, err := s.advancedAssignmentEnabled(ctx, accountID) if err != nil { return 0, fmt.Errorf("get advanced assignment feature: %w", err) } var conversation model.Conversation if err := s.db.WithContext(ctx). Select("id", "team_id", "last_activity_at"). Where("id = ? AND inbox_id = ? AND account_id = ?", conversationID, inboxID, accountID). First(&conversation).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, nil } return 0, fmt.Errorf("get conversation team: %w", err) } // Get eligible agents agents, err := s.getEligibleAgents(ctx, inboxID, accountID, conversation.TeamID, advancedAssignment) if err != nil { return 0, fmt.Errorf("get eligible agents: %w", err) } if advancedAssignment { agents, err = s.filterAgentsByCapacityExclusions(ctx, accountID, &conversation, agents) if err != nil { return 0, fmt.Errorf("apply capacity exclusions: %w", err) } } if len(agents) == 0 { return 0, nil } // Get policy and limits selector := EffectivePolicy(policy, advancedAssignment) limit := EffectiveLimit(policy) window := EffectiveWindow(policy) // Sync round-robin queue s.roundRobin.SyncQueue(ctx, inboxID, agents) // Select agent agentID, err := s.selectAgent(ctx, inboxID, agents, selector, limit, window) if err != nil { return 0, fmt.Errorf("select agent: %w", err) } if agentID == 0 { return 0, nil // all agents at rate limit } // Assign conversation assigned, err := s.assignConversation(ctx, accountID, inboxID, conversationID, agentID) if err != nil { return 0, fmt.Errorf("assign conversation: %w", err) } if !assigned { return 0, nil } // Track rate limit if err := s.rateLimiter.Increment(ctx, inboxID, agentID, window); err != nil { applogger.L().Errorf("failed to track assignment rate for inbox %d agent %d: %v", inboxID, agentID, err) } return agentID, nil } // selectAgent picks an agent for assignment using the selected policy // and rate limiting. func (s *AssignmentService) selectAgent(ctx context.Context, inboxID uint, agents []uint, policy AssignmentPolicyType, limit int, window int) (uint, error) { switch policy { case PolicyRoundRobin: return s.selectRoundRobin(ctx, inboxID, agents, limit, window) case PolicyLowestLoad: return s.selectLowestLoad(ctx, inboxID, agents, limit, window) default: return s.selectRoundRobin(ctx, inboxID, agents, limit, window) } } // selectRoundRobin selects an agent via round-robin, respecting rate limits. func (s *AssignmentService) selectRoundRobin(ctx context.Context, inboxID uint, agents []uint, limit int, window int) (uint, error) { // Try each agent in round-robin order for i := 0; i < len(agents); i++ { agentID, err := s.roundRobin.Next(ctx, inboxID) if err != nil || agentID == 0 { continue } // Check rate limit if s.rateLimiter.IsAllowed(ctx, inboxID, agentID, limit, window) { return agentID, nil } // Agent is at rate limit, skip and try next } // All agents at rate limit return 0, nil } // selectLowestLoad selects the agent with the fewest open conversations, // respecting rate limits. func (s *AssignmentService) selectLowestLoad(ctx context.Context, inboxID uint, agents []uint, limit int, window int) (uint, error) { agentID, err := s.lowestLoad.Select(ctx, inboxID, agents) if err != nil { return 0, fmt.Errorf("lowest load select: %w", err) } // Check rate limit for the selected agent if s.rateLimiter.IsAllowed(ctx, inboxID, agentID, limit, window) { return agentID, nil } // Selected agent is at rate limit — fall back to round-robin applogger.L().Debugf("lowest_load: agent %d at rate limit, falling back to round-robin", agentID) return s.selectRoundRobin(ctx, inboxID, agents, limit, window) } // findUnassignedConversations returns one bounded batch of open conversations // in the inbox that have no assignee. func (s *AssignmentService) findUnassignedConversations(ctx context.Context, inboxID uint, accountID uint, policy *model.AssignmentPolicy) ([]model.Conversation, error) { var conversations []model.Conversation query := s.db.WithContext(ctx). Where("inbox_id = ? AND account_id = ? AND status = ? AND assignee_id IS NULL", inboxID, accountID, model.ConversationStatusOpen) if policy != nil && policy.ExcludeOlderThanHours != nil && *policy.ExcludeOlderThanHours > 0 { cutoff := time.Now().Add(-time.Duration(*policy.ExcludeOlderThanHours) * time.Hour).Unix() // GoChat legacy rows may not have an activity timestamp; unknown is not stale. query = query.Where("last_activity_at IS NULL OR last_activity_at >= ?", cutoff) } if policy != nil && policy.ConversationPriority == 1 { // Unknown activity is not treated as longest waiting. PostgreSQL already // puts NULL last for ASC; the CASE makes SQLite follow the same contract. query = query.Order("CASE WHEN last_activity_at IS NULL THEN 1 ELSE 0 END ASC"). Order("last_activity_at ASC").Order("created_at ASC") } else { query = query.Order("created_at ASC") } err := query.Limit(assignmentBatchLimit).Find(&conversations).Error return conversations, err } // getEligibleAgents returns agents that are: // - Members of the inbox (via inbox_members table) // - Online/available (via user available field) func (s *AssignmentService) getEligibleAgents(ctx context.Context, inboxID uint, accountID uint, teamID *uint, advancedAssignment bool) ([]uint, error) { var agentIDs []uint query := s.db.WithContext(ctx). Table("inbox_members"). Distinct("inbox_members.user_id"). Joins("JOIN users ON users.id = inbox_members.user_id"). Joins("JOIN account_users ON account_users.user_id = inbox_members.user_id AND account_users.account_id = ?", accountID). Where("inbox_members.inbox_id = ? AND inbox_members.deleted_at IS NULL AND users.deleted_at IS NULL AND account_users.deleted_at IS NULL AND users.available = ? AND users.active = ? AND account_users.role IN ?", inboxID, true, true, []string{"agent", "administrator"}) if teamID != nil { query = query. Joins("JOIN team_members ON team_members.user_id = inbox_members.user_id AND team_members.team_id = ? AND team_members.deleted_at IS NULL", *teamID). Joins("JOIN teams ON teams.id = team_members.team_id AND teams.account_id = ? AND teams.allow_auto_assignment = ? AND teams.deleted_at IS NULL", accountID, true) } err := query. Order("inbox_members.user_id ASC"). Pluck("inbox_members.user_id", &agentIDs).Error if err != nil { return nil, err } availableIDs := make([]uint, 0, len(agentIDs)) for _, agentID := range agentIDs { if !advancedAssignment { availableIDs = append(availableIDs, agentID) continue } hasCapacity, err := s.agentHasInboxCapacity(ctx, accountID, inboxID, agentID, 0) if err != nil { return nil, err } if hasCapacity { availableIDs = append(availableIDs, agentID) } } return availableIDs, nil } func (s *AssignmentService) filterAgentsByCapacityExclusions(ctx context.Context, accountID uint, conversation *model.Conversation, agentIDs []uint) ([]uint, error) { eligible := make([]uint, 0, len(agentIDs)) for _, agentID := range agentIDs { excluded, err := s.capacityPolicyExcludesConversation(ctx, accountID, agentID, conversation) if err != nil { return nil, err } if !excluded { eligible = append(eligible, agentID) } } return eligible, nil } func (s *AssignmentService) capacityPolicyExcludesConversation(ctx context.Context, accountID, agentID uint, conversation *model.Conversation) (bool, error) { var accountUser model.AccountUser if err := s.db.WithContext(ctx). Where("account_id = ? AND user_id = ?", accountID, agentID). First(&accountUser).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return true, nil } return false, fmt.Errorf("load account user capacity policy: %w", err) } if accountUser.AgentCapacityPolicyID == nil { return false, nil } var policy model.AgentCapacityPolicy if err := s.db.WithContext(ctx). Where("id = ? AND account_id = ?", *accountUser.AgentCapacityPolicyID, accountID). First(&policy).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } return false, fmt.Errorf("load agent capacity policy: %w", err) } var rules struct { ExcludedLabels []string `json:"excluded_labels"` ExcludeOlderThanHours int `json:"exclude_older_than_hours"` } if len(policy.ExclusionRules) > 0 { if err := json.Unmarshal(policy.ExclusionRules, &rules); err != nil { return false, fmt.Errorf("decode capacity exclusion rules: %w", err) } } if rules.ExcludeOlderThanHours > 0 { cutoff := time.Now().Add(-time.Duration(rules.ExcludeOlderThanHours) * time.Hour).Unix() if conversation.LastActivityAt == nil || *conversation.LastActivityAt < cutoff { return true, nil } } if len(rules.ExcludedLabels) == 0 { return false, nil } var count int64 err := s.db.WithContext(ctx).Table("conversation_labels"). Joins("JOIN tags ON tags.id = conversation_labels.tag_id AND tags.deleted_at IS NULL"). Where("conversation_labels.conversation_id = ? AND conversation_labels.account_id = ? AND tags.account_id = ? AND tags.name IN ?", conversation.ID, accountID, accountID, rules.ExcludedLabels). Count(&count).Error if err != nil { return false, fmt.Errorf("check capacity exclusion labels: %w", err) } return count > 0, nil } func (s *AssignmentService) agentHasInboxCapacity(ctx context.Context, accountID, inboxID, agentID, excludeConversationID uint) (bool, error) { var accountUser model.AccountUser if err := s.db.WithContext(ctx). Where("account_id = ? AND user_id = ?", accountID, agentID). First(&accountUser).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } return false, fmt.Errorf("load account user capacity policy: %w", err) } if accountUser.AgentCapacityPolicyID == nil { return true, nil } var limit model.InboxCapacityLimit if err := s.db.WithContext(ctx). Where("agent_capacity_policy_id = ? AND inbox_id = ?", *accountUser.AgentCapacityPolicyID, inboxID). First(&limit).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return true, nil } return false, fmt.Errorf("load inbox capacity limit: %w", err) } q := s.db.WithContext(ctx).Model(&model.Conversation{}). Where("account_id = ? AND inbox_id = ? AND assignee_id = ? AND status = ?", accountID, inboxID, agentID, model.ConversationStatusOpen) if excludeConversationID != 0 { q = q.Where("id <> ?", excludeConversationID) } var assignedOpenCount int64 if err := q.Count(&assignedOpenCount).Error; err != nil { return false, fmt.Errorf("count assigned open conversations: %w", err) } return assignedOpenCount < int64(limit.ConversationLimit), nil } // getInbox fetches the inbox by ID. func (s *AssignmentService) getInbox(ctx context.Context, inboxID uint) (*model.Inbox, error) { var inbox model.Inbox if err := s.db.WithContext(ctx).First(&inbox, inboxID).Error; err != nil { return nil, err } return &inbox, nil } // getInboxPolicy resolves the current CRUD model through its inbox join. func (s *AssignmentService) getInboxPolicy(ctx context.Context, accountID, inboxID uint) (*model.AssignmentPolicy, error) { policy, err := repository.NewInboxAssignmentPolicyRepo(s.db).FindPolicyByInbox(ctx, accountID, inboxID) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return policy, err } func (s *AssignmentService) advancedAssignmentEnabled(ctx context.Context, accountID uint) (bool, error) { var account model.Account if err := s.db.WithContext(ctx).Select("feature_flags").First(&account, accountID).Error; err != nil { return false, err } return featureEnabled(account.FeatureFlags, "advanced_assignment"), nil } func featureEnabled(raw, flag string) bool { values := map[string]bool{} if json.Unmarshal([]byte(raw), &values) == nil { return values[flag] } var list []string if json.Unmarshal([]byte(raw), &list) == nil { for _, value := range list { if value == flag { return true } } return false } for _, value := range strings.Split(raw, ",") { if strings.TrimSpace(value) == flag { return true } } return false } // assignConversation sets the assignee_id on a conversation. func (s *AssignmentService) assignConversation(ctx context.Context, accountID, inboxID, conversationID, agentID uint) (bool, error) { return repository.AutoAssignConversation(ctx, s.db, accountID, inboxID, conversationID, agentID) }