From a98dc2ca426163b24fc3f30dc3231001ce32768f Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 06:56:09 +0800 Subject: [PATCH] feat(capacity): enforce inbox assignment limits --- internal/autoassignment/capacity_test.go | 77 +++++++++++++++++++ internal/autoassignment/service.go | 73 +++++++++++++++--- .../service/conversation_assignment_test.go | 62 ++++++++++++++- internal/service/conversation_service.go | 65 +++++++++++++++- 4 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 internal/autoassignment/capacity_test.go diff --git a/internal/autoassignment/capacity_test.go b/internal/autoassignment/capacity_test.go new file mode 100644 index 00000000..ed12d3d6 --- /dev/null +++ b/internal/autoassignment/capacity_test.go @@ -0,0 +1,77 @@ +package autoassignment + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/gochat/gochat/internal/model" +) + +func setupCapacityAssignmentDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.User{}, + &model.AccountUser{}, + &model.Inbox{}, + &model.InboxMember{}, + &model.Contact{}, + &model.Conversation{}, + &model.AgentCapacityPolicy{}, + &model.InboxCapacityLimit{}, + )) + return db +} + +func TestAssignmentService_SkipsAgentsAtInboxCapacity(t *testing.T) { + _, rdb := setupTestRedis(t) + db := setupCapacityAssignmentDB(t) + svc := NewAssignmentService(db, rdb) + ctx := context.Background() + + account := &model.Account{Name: "Capacity Account"} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Capacity Inbox", ChannelType: string(model.InboxChannelTypeWebWidget), EnableAutoAssignment: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Capacity Contact"} + require.NoError(t, db.Create(contact).Error) + + agentAtCapacity := createCapacityUser(t, db, account.ID, "full@test.com") + agentWithCapacity := createCapacityUser(t, db, account.ID, "available@test.com") + require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agentAtCapacity.ID}).Error) + require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agentWithCapacity.ID}).Error) + + policy := &model.AgentCapacityPolicy{AccountID: account.ID, Name: "Capacity", AssignmentLogic: "round_robin", ExclusionRules: []byte(`{}`)} + require.NoError(t, db.Create(policy).Error) + require.NoError(t, db.Model(&model.AccountUser{}). + Where("account_id = ? AND user_id = ?", account.ID, agentAtCapacity.ID). + Update("agent_capacity_policy_id", policy.ID).Error) + require.NoError(t, db.Create(&model.InboxCapacityLimit{AgentCapacityPolicyID: policy.ID, InboxID: inbox.ID, ConversationLimit: 1}).Error) + + now := time.Now().Unix() + existing := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &agentAtCapacity.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now} + unassigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now} + require.NoError(t, db.Create(existing).Error) + require.NoError(t, db.Create(unassigned).Error) + + agentID, err := svc.AssignConversation(ctx, unassigned.ID, inbox.ID, account.ID) + require.NoError(t, err) + assert.Equal(t, agentWithCapacity.ID, agentID) +} + +func createCapacityUser(t *testing.T, db *gorm.DB, accountID uint, email string) *model.User { + t.Helper() + user := &model.User{AccountID: accountID, Name: email, Email: email, Password: "hashed", Active: true, Available: true} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: accountID, UserID: user.ID, Role: "agent", Availability: "online"}).Error) + return user +} diff --git a/internal/autoassignment/service.go b/internal/autoassignment/service.go index 41a74731..8d922ad6 100644 --- a/internal/autoassignment/service.go +++ b/internal/autoassignment/service.go @@ -16,12 +16,13 @@ package autoassignment import ( "context" + "errors" "fmt" "github.com/gochat/gochat/internal/model" + applogger "github.com/gochat/gochat/pkg/logger" "github.com/redis/go-redis/v9" "gorm.io/gorm" - applogger "github.com/gochat/gochat/pkg/logger" ) // AssignmentService handles auto-assignment of conversations to agents. @@ -52,11 +53,11 @@ func NewAssignmentService(db *gorm.DB, rdb *redis.Client) *AssignmentService { // 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 +// 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) @@ -129,9 +130,9 @@ func (s *AssignmentService) AssignUnassignedConversations(ctx context.Context, i // 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 +// 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) @@ -266,7 +267,57 @@ func (s *AssignmentService) getEligibleAgents(ctx context.Context, inboxID uint, Where("inbox_members.inbox_id = ? AND users.available = ? AND users.active = ?", inboxID, true, true). Pluck("inbox_members.user_id", &agentIDs).Error - return agentIDs, err + if err != nil { + return nil, err + } + + availableIDs := make([]uint, 0, len(agentIDs)) + for _, agentID := range agentIDs { + 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) 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. @@ -308,4 +359,4 @@ func (s *AssignmentService) assignConversation(ctx context.Context, conversation Model(&model.Conversation{}). Where("id = ?", conversationID). Update("assignee_id", agentID).Error -} \ No newline at end of file +} diff --git a/internal/service/conversation_assignment_test.go b/internal/service/conversation_assignment_test.go index f803c7b7..617b08cf 100644 --- a/internal/service/conversation_assignment_test.go +++ b/internal/service/conversation_assignment_test.go @@ -35,6 +35,8 @@ func setupAssignmentTestDB(t *testing.T) *gorm.DB { &model.Conversation{}, &model.Team{}, &model.TeamMember{}, + &model.AgentCapacityPolicy{}, + &model.InboxCapacityLimit{}, } require.NoError(t, db.AutoMigrate(models...)) return db @@ -114,6 +116,21 @@ func createAssignmentConversation(t *testing.T, db *gorm.DB, accountID, inboxID, return conv } +func createAssignmentCapacityLimit(t *testing.T, db *gorm.DB, accountID, inboxID, userID uint, conversationLimit int) *model.AgentCapacityPolicy { + t.Helper() + policy := &model.AgentCapacityPolicy{AccountID: accountID, Name: "Capacity", AssignmentLogic: "round_robin", ExclusionRules: []byte(`{}`)} + require.NoError(t, db.Create(policy).Error) + require.NoError(t, db.Model(&model.AccountUser{}). + Where("account_id = ? AND user_id = ?", accountID, userID). + Update("agent_capacity_policy_id", policy.ID).Error) + require.NoError(t, db.Create(&model.InboxCapacityLimit{ + AgentCapacityPolicyID: policy.ID, + InboxID: inboxID, + ConversationLimit: conversationLimit, + }).Error) + return policy +} + func createAssignmentTeam(t *testing.T, db *gorm.DB, accountID uint, name string, allowAutoAssignment bool) *model.Team { t.Helper() team := &model.Team{AccountID: accountID, Name: name, AllowAutoAssignment: true} // SQLite default:true workaround — create as true first @@ -208,6 +225,49 @@ func TestAssignAgent_RejectsInboxNonMember(t *testing.T) { assert.Contains(t, err.Error(), "not a member of the conversation's inbox") } +func TestAssignAgent_RejectsAgentAtInboxCapacity(t *testing.T) { + svc, db := setupAssignmentService(t) + ctx := context.Background() + + account := createAssignmentAccount(t, db) + inbox := createAssignmentInbox(t, db, account.ID) + contact := createAssignmentContact(t, db, account.ID) + existing := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) + conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) + + agent := createAssignmentUser(t, db, "CapacityAgent", "capacity@test.com") + createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") + createAssignmentInboxMember(t, db, inbox.ID, agent.ID) + createAssignmentCapacityLimit(t, db, account.ID, inbox.ID, agent.ID, 1) + require.NoError(t, db.Model(existing).Update("assignee_id", agent.ID).Error) + + _, err := svc.AssignAgent(ctx, account.ID, conv.ID, agent.ID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "reached capacity") +} + +func TestAssignAgent_IgnoresResolvedConversationsForCapacity(t *testing.T) { + svc, db := setupAssignmentService(t) + ctx := context.Background() + + account := createAssignmentAccount(t, db) + inbox := createAssignmentInbox(t, db, account.ID) + contact := createAssignmentContact(t, db, account.ID) + resolved := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) + conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) + + agent := createAssignmentUser(t, db, "CapacityAgent2", "capacity2@test.com") + createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") + createAssignmentInboxMember(t, db, inbox.ID, agent.ID) + createAssignmentCapacityLimit(t, db, account.ID, inbox.ID, agent.ID, 1) + require.NoError(t, db.Model(resolved).Updates(map[string]any{"assignee_id": agent.ID, "status": model.ConversationStatusResolved}).Error) + + result, err := svc.AssignAgent(ctx, account.ID, conv.ID, agent.ID) + assert.NoError(t, err) + require.NotNil(t, result.AssigneeID) + assert.Equal(t, agent.ID, *result.AssigneeID) +} + func TestAssignAgent_Unassign_SetsNilAssigneeID(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() @@ -492,4 +552,4 @@ func TestAccountUserRepo_FindByAccountAndUser(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, au) assert.Equal(t, "agent", au.Role) -} \ No newline at end of file +} diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 58731f80..e783328c 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -303,6 +303,10 @@ func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uin return nil, errors.New("assignee is not a member of the conversation's inbox") } + if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, assigneeID); err != nil { + return nil, err + } + if err := s.repo.AssignAgent(ctx, conversation.ID, assigneeID); err != nil { return nil, err } @@ -972,6 +976,9 @@ func (s *ConversationService) AssignTeam(ctx context.Context, accountID, convers if s.inboxMemberSvc != nil && !s.inboxMemberSvc.IsMemberOfInbox(ctx, conversation.InboxID, *agentID) { return nil, errors.New("assignee is not a member of the conversation's inbox") } + if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, *agentID); err != nil { + return nil, err + } conversation.AssigneeID = agentID } @@ -1002,9 +1009,16 @@ func (s *ConversationService) AssignTeam(ctx context.Context, accountID, convers applogger.L().Errorf("failed to find online agents for overflow: %v", err) } else if len(onlineAgents) > 0 { // Assign first available online agent as overflow - fallbackID := onlineAgents[0].UserID - conversation.AssigneeID = &fallbackID - applogger.L().Infof("overflow assigned agent %d from account %d online pool", fallbackID, accountID) + for _, onlineAgent := range onlineAgents { + fallbackID := onlineAgent.UserID + if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, fallbackID); err != nil { + applogger.L().Infof("overflow skipped agent %d due to capacity: %v", fallbackID, err) + continue + } + conversation.AssigneeID = &fallbackID + applogger.L().Infof("overflow assigned agent %d from account %d online pool", fallbackID, accountID) + break + } } } } @@ -1020,3 +1034,48 @@ func (s *ConversationService) AssignTeam(ctx context.Context, accountID, convers s.indexConversation(ctx, conversation) return conversation, nil } + +func (s *ConversationService) ensureAssigneeHasInboxCapacity(ctx context.Context, accountID uint, conversation *model.Conversation, assigneeID uint) error { + if conversation == nil || conversation.AssigneeID != nil && *conversation.AssigneeID == assigneeID { + return nil + } + db := s.DB() + if db == nil { + return nil + } + + var accountUser model.AccountUser + if err := db.WithContext(ctx). + Where("account_id = ? AND user_id = ?", accountID, assigneeID). + First(&accountUser).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("assignee is not an agent or administrator in this account") + } + return fmt.Errorf("load assignee capacity policy: %w", err) + } + if accountUser.AgentCapacityPolicyID == nil { + return nil + } + + var limit model.InboxCapacityLimit + if err := db.WithContext(ctx). + Where("agent_capacity_policy_id = ? AND inbox_id = ?", *accountUser.AgentCapacityPolicyID, conversation.InboxID). + First(&limit).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return fmt.Errorf("load inbox capacity limit: %w", err) + } + + q := db.WithContext(ctx).Model(&model.Conversation{}). + Where("account_id = ? AND inbox_id = ? AND assignee_id = ? AND status = ?", accountID, conversation.InboxID, assigneeID, model.ConversationStatusOpen). + Where("id <> ?", conversation.ID) + var assignedOpenCount int64 + if err := q.Count(&assignedOpenCount).Error; err != nil { + return fmt.Errorf("count assigned open conversations: %w", err) + } + if assignedOpenCount >= int64(limit.ConversationLimit) { + return fmt.Errorf("assignee has reached capacity for this inbox") + } + return nil +}