feat(settings): align assignable agents payloads

This commit is contained in:
2026-06-05 23:54:37 +08:00
parent dc09036676
commit 5efa1955e5
4 changed files with 137 additions and 128 deletions
File diff suppressed because one or more lines are too long
@@ -80,5 +80,39 @@ func (h *AssignableAgentHandler) List(c *gin.Context) {
return
}
response.OK(c, agents)
c.JSON(http.StatusOK, gin.H{"payload": serializeAssignableAgents(accountID, agents)})
}
func serializeAssignableAgents(accountID uint, agents []service.AssignableAgentDTO) []map[string]any {
payload := make([]map[string]any, 0, len(agents))
for i := range agents {
agent := agents[i]
availableName := agent.DisplayName
if availableName == "" {
availableName = agent.Name
}
provider := agent.Provider
if provider == "" {
provider = "email"
}
customRoleID := any(nil)
if agent.CustomRoleID != 0 {
customRoleID = agent.CustomRoleID
}
payload = append(payload, map[string]any{
"id": agent.ID,
"account_id": accountID,
"availability_status": agent.AvailabilityStatus,
"auto_offline": agent.AutoOffline,
"confirmed": agent.Confirmed,
"email": agent.Email,
"provider": provider,
"available_name": availableName,
"name": agent.Name,
"role": agent.Role,
"thumbnail": agent.AvatarURL,
"custom_role_id": customRoleID,
})
}
return payload
}
@@ -27,14 +27,14 @@ type AssignableAgentHandlerTestSuite struct {
router *gin.Engine
handler *AssignableAgentHandler
account *model.Account
inbox1 *model.Inbox
inbox2 *model.Inbox
inbox3 *model.Inbox
user1 *model.User // agent in inbox1
user2 *model.User // agent in inbox1 + inbox2
user3 *model.User // administrator of account
user4 *model.User // agent only in inbox2
account *model.Account
inbox1 *model.Inbox
inbox2 *model.Inbox
inbox3 *model.Inbox
user1 *model.User // agent in inbox1
user2 *model.User // agent in inbox1 + inbox2
user3 *model.User // administrator of account
user4 *model.User // agent only in inbox2
}
func (s *AssignableAgentHandlerTestSuite) SetupSuite() {
@@ -168,6 +168,15 @@ func (s *AssignableAgentHandlerTestSuite) TearDownTest() {
s.reseedData()
}
func (s *AssignableAgentHandlerTestSuite) decodeAssignablePayload(w *httptest.ResponseRecorder) []interface{} {
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Require().NotContains(resp, "success")
payload, ok := resp["payload"].([]interface{})
s.Require().True(ok, "expected Chatwoot payload array, got %v", resp)
return payload
}
func (s *AssignableAgentHandlerTestSuite) reseedData() {
// Re-create the AccountUser + InboxMember associations
s.Require().NoError(s.db.Create(&model.AccountUser{
@@ -211,13 +220,16 @@ func (s *AssignableAgentHandlerTestSuite) TestList_SingleInbox_ReturnsInboxMembe
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Should include user1, user2 (inbox members) + user3 (administrator) = 3
s.Len(data, 3)
agent := data[0].(map[string]interface{})
s.Contains(agent, "availability_status")
s.Contains(agent, "available_name")
s.Contains(agent, "auto_offline")
s.Contains(agent, "confirmed")
s.Contains(agent, "thumbnail")
s.Contains(agent, "custom_role_id")
}
func (s *AssignableAgentHandlerTestSuite) TestList_InboxWithOnlyOneMember() {
@@ -229,11 +241,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_InboxWithOnlyOneMember() {
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// user2, user4 (inbox2 members) + user3 (administrator) = 3
s.Len(data, 3)
}
@@ -247,11 +255,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_InboxWithNoMembers_ReturnsOnl
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Only administrator user3
s.Len(data, 1)
}
@@ -268,11 +272,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParam()
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Intersection: user2. Admin: user3. Total: 2
s.Len(data, 2)
}
@@ -289,11 +289,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParams_N
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Empty intersection + admin user3
s.Len(data, 1)
}
@@ -309,11 +305,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_QueryParamSameAsPrimaryInboxI
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Same as single inbox1: user1, user2, user3
s.Len(data, 3)
}
@@ -332,11 +324,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_MultipleAdditionalInboxIDs()
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
s.Len(data, 1)
}
@@ -398,11 +386,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_InvalidQueryParamInboxIDs_Ign
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Same as just inbox1: user1, user2, user3
s.Len(data, 3)
}
@@ -417,11 +401,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_NonExistentInboxID() {
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// No inbox members, only administrators: user3
s.Len(data, 1)
}
@@ -437,11 +417,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_NonExistentAccountID() {
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Inbox members of inbox1: user1, user2. No admins for account 99999.
s.Len(data, 2)
}
@@ -543,10 +519,8 @@ func (s *AssignableAgentHandlerTestSuite) TestList_NilService_PanicRecovered() {
s.Equal(http.StatusUnprocessableEntity, w.Code)
}
func (s *AssignableAgentHandlerTestSuite) TestList_ResponseStructure() {
// Verify the response structure matches: {success: true, data: [...]}
// Verify the response structure matches Chatwoot: {payload: [...]}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET",
fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/assignable_agents", s.account.ID, s.inbox1.ID), nil)
@@ -557,10 +531,9 @@ func (s *AssignableAgentHandlerTestSuite) TestList_ResponseStructure() {
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
// Must have "success" key set to true
s.True(resp["success"].(bool))
// Must have "data" key
s.NotNil(resp["data"])
s.NotContains(resp, "success")
s.NotContains(resp, "data")
s.NotNil(resp["payload"])
// No "meta" key for non-paginated response
s.Nil(resp["meta"])
// No "error" key for success response
@@ -581,11 +554,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_AdministratorDeduplication()
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// user1, user2 (inbox members), user3 (admin AND inbox member) - deduplicated
// Should be 3, not 4 (user3 counted once)
s.Len(data, 3)
@@ -609,11 +578,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_AccountWithNoAdministrators()
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
// Only inbox member user1 (no administrators added)
s.Len(data, 1)
}
@@ -627,9 +592,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_LargeAccountID() {
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
s.decodeAssignablePayload(w)
}
func (s *AssignableAgentHandlerTestSuite) TestList_NegativeAccountID_ParseError() {
@@ -705,11 +668,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_TwoInboxesWithPartialIntersec
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
s.Len(data, 2)
}
@@ -728,15 +687,11 @@ func (s *AssignableAgentHandlerTestSuite) TestList_ThreeInboxesIntersectionWithT
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp["success"].(bool))
data := resp["data"].([]interface{})
data := s.decodeAssignablePayload(w)
s.Len(data, 2)
}
// Run the test suite
func TestAssignableAgentHandlerSuite(t *testing.T) {
suite.Run(t, new(AssignableAgentHandlerTestSuite))
}
}
+50 -32
View File
@@ -14,35 +14,41 @@ import (
// 逻辑: 对每个inbox在inbox_ids[]中的成员取交集,加上account管理员,去重。
// 增强功能: 计算每个agent的workload (open conversations数量),按workload升序排序。
type AssignableAgentService struct {
inboxMemberRepo *repository.InboxMemberRepo
userRepo *repository.UserRepo
accountRepo *repository.AccountRepo
conversationRepo *repository.ConversationRepo
inboxMemberRepo *repository.InboxMemberRepo
userRepo *repository.UserRepo
accountRepo *repository.AccountRepo
conversationRepo *repository.ConversationRepo
}
// NewAssignableAgentService 创建新的AssignableAgentService。
func NewAssignableAgentService(inboxMemberRepo *repository.InboxMemberRepo, userRepo *repository.UserRepo, accountRepo *repository.AccountRepo, conversationRepo *repository.ConversationRepo) *AssignableAgentService {
return &AssignableAgentService{
inboxMemberRepo: inboxMemberRepo,
userRepo: userRepo,
accountRepo: accountRepo,
conversationRepo: conversationRepo,
inboxMemberRepo: inboxMemberRepo,
userRepo: userRepo,
accountRepo: accountRepo,
conversationRepo: conversationRepo,
}
}
// AssignableAgentDTO 是可分配agent的API响应结构,包含workload信息。
// Reference: Chatwoot assignable_agents API — 返回agent列表及其当前workload
type AssignableAgentDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
Available bool `json:"available"`
AvatarURL string `json:"avatar_url"`
AvailabilityStatus string `json:"availability_status"` // online, offline, busy
Workload int64 `json:"workload"` // 当前open conversations数量
IsAdministrator bool `json:"is_administrator"` // 是否为account管理员
ID uint `json:"id"`
AccountID uint `json:"account_id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
Available bool `json:"available"`
Provider string `json:"provider"`
DisplayName string `json:"available_name"`
AvatarURL string `json:"avatar_url"`
AutoOffline bool `json:"auto_offline"`
Confirmed bool `json:"confirmed"`
CustomRoleID uint `json:"custom_role_id,omitempty"`
AvailabilityStatus string `json:"availability_status"` // online, offline, busy
Workload int64 `json:"workload"` // 当前open conversations数量
IsAdministrator bool `json:"is_administrator"` // 是否为account管理员
}
// FindAssignableAgents 返回可以被分配到指定inbox对话中的agents。
@@ -131,36 +137,48 @@ func (s *AssignableAgentService) GetAssignableAgents(ctx context.Context, accoun
}
}
// 获取account的管理员IDs用于标记is_administrator
adminIDs, _ := s.findAdministratorIDs(ctx, accountID)
adminSet := make(map[uint]bool, len(adminIDs))
for _, id := range adminIDs {
adminSet[id] = true
}
// 获取每个agent的availability status (从account_user表中获取)
availabilityMap := make(map[uint]string)
// 获取每个agent的AccountUser字段,用于复用Chatwoot _agent serializer shape。
accountUserMap := make(map[uint]model.AccountUser)
accountUsers, _, auErr := s.accountRepo.FindAgentsByAccount(ctx, accountID, 0, 1000)
if auErr == nil {
for _, au := range accountUsers {
availabilityMap[au.UserID] = au.Availability
accountUserMap[au.UserID] = au
}
}
// 构建DTO列表
dtos := make([]AssignableAgentDTO, len(users))
for i, u := range users {
accountUser := accountUserMap[u.ID]
role := accountUser.Role
if role == "" {
role = u.Role
}
availabilityStatus := accountUser.Availability
if availabilityStatus == "" {
if u.Available {
availabilityStatus = "online"
} else {
availabilityStatus = "offline"
}
}
dtos[i] = AssignableAgentDTO{
ID: u.ID,
AccountID: accountID,
Name: u.Name,
Email: u.Email,
Role: u.Role,
Role: role,
Active: u.Active,
Available: u.Available,
Provider: u.Provider,
DisplayName: u.DisplayName,
AvatarURL: u.AvatarURL,
AvailabilityStatus: availabilityMap[u.ID],
AutoOffline: accountUser.AutoOffline,
Confirmed: u.ConfirmedAt != nil,
CustomRoleID: accountUser.CustomRoleID,
AvailabilityStatus: availabilityStatus,
Workload: workloadMap[u.ID],
IsAdministrator: adminSet[u.ID],
IsAdministrator: role == "administrator",
}
}
@@ -232,4 +250,4 @@ func union(a, b []uint) []uint {
}
}
return result
}
}