feat(conversations): align participant payloads

This commit is contained in:
2026-06-06 14:29:18 +08:00
parent a142a3c4c4
commit 6b90aedc83
9 changed files with 307 additions and 72 deletions
@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
)
@@ -40,7 +41,7 @@ func (h *ConversationParticipantHandler) List(c *gin.Context) {
return
}
response.OK(c, toInterfaceSlice(participants))
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, participants))
}
// Add adds a participant to a conversation.
@@ -59,21 +60,27 @@ func (h *ConversationParticipantHandler) Add(c *gin.Context) {
}
var req struct {
UserID uint `json:"user_id" binding:"required"`
Role string `json:"role"`
UserID uint `json:"user_id"`
UserIDs []uint `json:"user_ids"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
userIDs := participantRequestUserIDs(req.UserID, req.UserIDs)
if len(userIDs) == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "user_ids is required")
return
}
participant, svcErr := h.participantSvc.Add(c.Request.Context(), accountID, conversationID, req.UserID, req.Role)
participants, svcErr := h.participantSvc.AddMany(c.Request.Context(), accountID, conversationID, userIDs, req.Role)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, participant)
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, participants))
}
// Update updates a participant's role in a conversation.
@@ -111,7 +118,7 @@ func (h *ConversationParticipantHandler) Update(c *gin.Context) {
return
}
response.OK(c, participant)
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, []model.ConversationParticipant{*participant}))
}
// Remove removes a participant from a conversation.
@@ -140,12 +147,45 @@ func (h *ConversationParticipantHandler) Remove(c *gin.Context) {
return
}
response.OK(c, nil)
c.Status(http.StatusOK)
}
// Destroy removes multiple participants from a conversation.
// DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants
func (h *ConversationParticipantHandler) Destroy(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
conversationID, err := parseUintParam(c, "conversation_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
var req struct {
UserIDs []uint `json:"user_ids"`
}
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
}
if svcErr := h.participantSvc.RemoveMany(c.Request.Context(), accountID, conversationID, req.UserIDs); svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.Status(http.StatusOK)
}
// BatchUpdate adds and/or removes multiple participants in a single call.
// PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/participants
// Accepts: { "user_ids": [1,2,3], "remove_user_ids": [4,5], "role": "participant" }
// Accepts: { "user_ids": [1,2,3] } as the final participant set.
// Reference: Chatwoot app/controllers/api/v1/conversations/participants_controller.rb#update
func (h *ConversationParticipantHandler) BatchUpdate(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
@@ -161,20 +201,37 @@ func (h *ConversationParticipantHandler) BatchUpdate(c *gin.Context) {
}
var req struct {
UserIDs []uint `json:"user_ids"`
RemoveUserIDs []uint `json:"remove_user_ids"`
Role string `json:"role"`
UserIDs []uint `json:"user_ids"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
participants, svcErr := h.participantSvc.BatchUpdate(c.Request.Context(), accountID, conversationID, req.UserIDs, req.RemoveUserIDs, req.Role)
participants, svcErr := h.participantSvc.Replace(c.Request.Context(), accountID, conversationID, req.UserIDs, req.Role)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, toInterfaceSlice(participants))
}
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, participants))
}
func participantRequestUserIDs(userID uint, userIDs []uint) []uint {
if len(userIDs) > 0 {
return userIDs
}
if userID != 0 {
return []uint{userID}
}
return nil
}
func serializeConversationParticipantAgents(accountID uint, participants []model.ConversationParticipant) []map[string]any {
payload := make([]map[string]any, 0, len(participants))
for i := range participants {
payload = append(payload, serializeAgentUser(participants[i].User, accountID, "", "", false, 0))
}
return payload
}
@@ -91,8 +91,13 @@ func (s *ConversationParticipantHandlerTestSuite) SetupSuite() {
participants := convGroup.Group("/participants")
{
participants.GET("", handler.List)
participants.GET("/", handler.List)
participants.POST("", handler.Add)
participants.POST("/", handler.Add)
participants.PATCH("", handler.BatchUpdate)
participants.PATCH("/", handler.BatchUpdate)
participants.PUT("", handler.BatchUpdate)
participants.DELETE("", handler.Destroy)
participants.PATCH("/:user_id", handler.Update)
participants.DELETE("/:user_id", handler.Remove)
}
@@ -102,8 +107,7 @@ func (s *ConversationParticipantHandlerTestSuite) SetupSuite() {
func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant() {
body := map[string]interface{}{
"user_id": s.testUser.ID,
"role": "assignee",
"user_ids": []uint{s.testUser.ID},
}
b, _ := json.Marshal(body)
@@ -114,13 +118,13 @@ func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data, ok := resp["data"].([]interface{})
if ok && len(data) > 0 {
item := data[0].(map[string]interface{})
assert.Equal(s.T(), "assignee", item["role"])
}
var resp []map[string]interface{}
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 1)
assert.Equal(s.T(), float64(s.testUser.ID), resp[0]["id"])
assert.Equal(s.T(), s.testUser.Email, resp[0]["email"])
assert.NotContains(s.T(), resp[0], "conversation_id")
assert.NotContains(s.T(), resp[0], "user_id")
}
func (s *ConversationParticipantHandlerTestSuite) Test_ListParticipants() {
@@ -142,6 +146,10 @@ func (s *ConversationParticipantHandlerTestSuite) Test_ListParticipants() {
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp []map[string]interface{}
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.NotEmpty(s.T(), resp)
assert.NotContains(s.T(), resp[0], "data")
}
func (s *ConversationParticipantHandlerTestSuite) Test_RemoveParticipant() {
@@ -210,36 +218,62 @@ func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant_InvalidAcc
// ========== BatchUpdate Handler Tests ==========
func (s *ConversationParticipantHandlerTestSuite) Test_BatchUpdate_AddAndRemove() {
// Create a second user for testing batch add/remove
user1 := &model.User{Name: "BatchUser1", Email: "batch1@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(user1).Error)
user2 := &model.User{Name: "BatchUser2", Email: "batch2@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(user2).Error)
conv := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(conv).Error)
// First add s.testUser as participant
// First add user1 as participant.
addBody := map[string]interface{}{
"user_id": s.testUser.ID,
"role": "assignee",
"user_ids": []uint{user1.ID},
}
addBytes, _ := json.Marshal(addBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(addBytes))
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(addBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
// Now batch update: add user2, remove s.testUser
// Chatwoot update treats user_ids as the final participant set.
batchBody := map[string]interface{}{
"user_ids": []uint{user2.ID},
"removed_user_ids": []uint{s.testUser.ID},
"user_ids": []uint{user2.ID},
}
batchBytes, _ := json.Marshal(batchBody)
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(batchBytes))
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(batchBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp []map[string]interface{}
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 1)
assert.Equal(s.T(), float64(user2.ID), resp[0]["id"])
var count int64
s.Require().NoError(s.db.Model(&model.ConversationParticipant{}).Where("conversation_id = ? AND user_id = ?", conv.ID, user1.ID).Count(&count).Error)
assert.Equal(s.T(), int64(0), count)
}
func (s *ConversationParticipantHandlerTestSuite) Test_DestroyParticipantsRawPayload() {
user := &model.User{Name: "DestroyUser", Email: "destroy-participant@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(user).Error)
conv := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(conv).Error)
s.Require().NoError(s.db.Create(&model.ConversationParticipant{AccountID: s.testAccount.ID, ConversationID: conv.ID, UserID: user.ID}).Error)
body, _ := json.Marshal(map[string]interface{}{"user_ids": []uint{user.ID}})
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
}
func (s *ConversationParticipantHandlerTestSuite) Test_BatchUpdate_InvalidAccountID() {