feat(conversations): finish message mutation parity

This commit is contained in:
2026-06-05 02:10:42 +08:00
parent 5c44ca0c50
commit a465bbe09d
10 changed files with 420 additions and 42 deletions
@@ -2,6 +2,7 @@ package v1
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -575,9 +576,10 @@ func (h *ConversationHandler) ListMessages(c *gin.Context) {
return
}
p := pagination.Parse(c)
messages, _, svcErr := h.conversationSvc.ListMessages(c.Request.Context(), conversation.ID, p.Offset, p.PerPage)
after, _ := strconv.ParseUint(c.Query("after"), 10, 64)
before, _ := strconv.ParseUint(c.Query("before"), 10, 64)
filterInternal := c.Query("filter_internal_messages") != ""
messages, _, svcErr := h.messageSvc.ListByConversationFinder(c.Request.Context(), conversation.ID, uint(after), uint(before), filterInternal)
if svcErr != nil {
handleServiceError(c, svcErr)
return
@@ -857,6 +859,10 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) {
c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.AgentID, accountID))
return
}
if req.TeamID != nil {
c.JSON(http.StatusOK, serializeTeamFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.TeamID, accountID))
return
}
c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation))
}
@@ -876,6 +882,10 @@ func handleServiceError(c *gin.Context, err error) {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, errMsg)
return
}
if strings.Contains(lower, "only allowed") || strings.Contains(lower, "forbidden") {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, errMsg)
return
}
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, errMsg)
}
@@ -896,7 +896,10 @@ func (s *ConversationCrudTestSuite) TestAssignTeam_Success() {
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(s.T(), err)
assert.Equal(s.T(), float64(s.testConv.ID), resp["id"])
assert.Equal(s.T(), float64(team.ID), resp["id"])
assert.Equal(s.T(), "TestTeam", resp["name"])
assert.Equal(s.T(), float64(s.testAccount.ID), resp["account_id"])
assert.Nil(s.T(), resp["meta"])
}
func (s *ConversationCrudTestSuite) TestAssignTeam_WithAgentID() {
@@ -3,6 +3,7 @@ package v1
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"time"
@@ -177,6 +178,12 @@ func serializeConversationMeta(ctx context.Context, db *gorm.DB, conversation *m
meta.AssigneeType = "User"
}
}
if conversation.TeamID != nil && *conversation.TeamID != 0 {
var team model.Team
if err := db.WithContext(ctx).First(&team, *conversation.TeamID).Error; err == nil {
meta.Team = serializeTeam(&team)
}
}
if conversation.ContactInboxID != nil && *conversation.ContactInboxID != 0 {
var contactInbox model.ContactInbox
@@ -249,9 +256,35 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message,
}
}
}
if db != nil {
var attachments []model.Attachment
if err := db.WithContext(ctx).Where("message_id = ?", message.ID).Order("id ASC").Find(&attachments).Error; err == nil && len(attachments) > 0 {
payload.Attachments = make([]any, 0, len(attachments))
for i := range attachments {
payload.Attachments = append(payload.Attachments, serializeAttachment(&attachments[i]))
}
}
}
return payload
}
func serializeAttachment(attachment *model.Attachment) map[string]any {
extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".")
dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL)
return map[string]any{
"id": attachment.ID,
"message_id": attachment.MessageID,
"file_type": attachment.FileType,
"account_id": attachment.AccountID,
"data_url": dataURL,
"thumb_url": attachment.ThumbURL,
"file_size": attachment.FileSize,
"extension": extension,
"width": attachment.Width,
"height": attachment.Height,
}
}
func serializeContact(contact *model.Contact) map[string]any {
return map[string]any{
"additional_attributes": jsonObject(contact.AdditionalAttributes),
@@ -296,6 +329,29 @@ func serializeUserFromDB(ctx context.Context, db *gorm.DB, userID uint, accountI
return serializeUser(&user, accountID)
}
func serializeTeam(team *model.Team) map[string]any {
return map[string]any{
"id": team.ID,
"account_id": team.AccountID,
"name": team.Name,
"description": team.Description,
"allow_auto_assignment": team.AllowAutoAssignment,
"created_at": team.CreatedAt.Unix(),
"updated_at": team.UpdatedAt.Unix(),
}
}
func serializeTeamFromDB(ctx context.Context, db *gorm.DB, teamID uint, accountID uint) any {
if teamID == 0 || db == nil {
return nil
}
var team model.Team
if err := db.WithContext(ctx).Where("id = ? AND account_id = ?", teamID, accountID).First(&team).Error; err != nil {
return nil
}
return serializeTeam(&team)
}
func conversationDisplayID(conversation *model.Conversation) uint {
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return *conversation.DisplayID
+22 -6
View File
@@ -3,6 +3,7 @@ package v1
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -61,9 +62,10 @@ func (h *MessageHandler) List(c *gin.Context) {
return
}
p := pagination.Parse(c)
messages, _, svcErr := h.svc.ListByConversation(c.Request.Context(), conversation.ID, p.Offset, p.PerPage)
after, _ := strconv.ParseUint(c.Query("after"), 10, 64)
before, _ := strconv.ParseUint(c.Query("before"), 10, 64)
filterInternal := c.Query("filter_internal_messages") != ""
messages, _, svcErr := h.svc.ListByConversationFinder(c.Request.Context(), conversation.ID, uint(after), uint(before), filterInternal)
if svcErr != nil {
handleServiceError(c, svcErr)
return
@@ -168,7 +170,8 @@ func (h *MessageHandler) Get(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
response.OK(c, message)
conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID)
c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation))
}
// Update updates a message's content.
@@ -229,11 +232,13 @@ func (h *MessageHandler) Delete(c *gin.Context) {
return
}
if svcErr := h.svc.Delete(c.Request.Context(), accountID, messageID); svcErr != nil {
message, svcErr := h.svc.Delete(c.Request.Context(), accountID, messageID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.NoContent(c)
conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID)
c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation))
}
// Search searches messages by content within an account.
@@ -339,6 +344,17 @@ func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest)
if raw := c.PostForm("content_attributes"); raw != "" {
req.ContentAttributes = []byte(raw)
}
if c.Request.MultipartForm != nil {
for _, key := range []string{"attachments[]", "attachments"} {
for _, file := range c.Request.MultipartForm.File[key] {
req.Attachments = append(req.Attachments, service.MessageAttachmentInput{
FileName: file.Filename,
FileSize: int(file.Size),
ContentType: file.Header.Get("Content-Type"),
})
}
}
}
return nil
}
+129 -5
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
@@ -80,6 +81,7 @@ func (s *MessageHandlerTestSuite) SetupSuite() {
&model.Conversation{},
&model.ConversationParticipant{},
&model.Message{},
&model.Attachment{},
&model.InboxMember{},
)
s.Require().NoError(err)
@@ -185,6 +187,7 @@ func (s *MessageHandlerTestSuite) SetupTest() {
}
func (s *MessageHandlerTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM attachments")
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM conversation_participants")
s.db.Exec("DELETE FROM conversations")
@@ -253,6 +256,47 @@ func (s *MessageHandlerTestSuite) TestList_Empty() {
assert.Equal(s.T(), 0, len(data))
}
func (s *MessageHandlerTestSuite) TestList_BeforeAfterMessageFinder() {
s.db.Exec("DELETE FROM messages")
var ids []uint
for i := 0; i < 5; i++ {
msg := &model.Message{
ConversationID: s.testConv.ID,
AccountID: s.testAccount.ID,
InboxID: s.testInbox.ID,
Content: fmt.Sprintf("message-%d", i+1),
ContentType: "text",
MessageType: "incoming",
SenderType: "contact",
}
s.Require().NoError(s.db.Create(msg).Error)
ids = append(ids, msg.ID)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", msgListURL(s.testAccount.ID, s.testConv.ID)+fmt.Sprintf("?before=%d", ids[3]), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var beforeResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &beforeResp)
beforePayload := beforeResp["payload"].([]interface{})
assert.Len(s.T(), beforePayload, 3)
assert.Equal(s.T(), float64(ids[0]), beforePayload[0].(map[string]interface{})["id"])
assert.Equal(s.T(), float64(ids[2]), beforePayload[2].(map[string]interface{})["id"])
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", msgListURL(s.testAccount.ID, s.testConv.ID)+fmt.Sprintf("?after=%d", ids[2]), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var afterResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &afterResp)
afterPayload := afterResp["payload"].([]interface{})
assert.Len(s.T(), afterPayload, 2)
assert.Equal(s.T(), float64(ids[3]), afterPayload[0].(map[string]interface{})["id"])
}
func (s *MessageHandlerTestSuite) TestList_InvalidConversationID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/abc/messages/", nil)
@@ -313,6 +357,37 @@ func (s *MessageHandlerTestSuite) TestCreate_ChatwootFrontendPayloadDefaultsOutg
assert.NotNil(s.T(), resp["content_attributes"])
}
func (s *MessageHandlerTestSuite) TestCreate_MultipartAttachmentPersistsAndSerializes() {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
s.Require().NoError(writer.WriteField("content", "Attachment message"))
s.Require().NoError(writer.WriteField("private", "false"))
file, err := writer.CreateFormFile("attachments[]", "hello.txt")
s.Require().NoError(err)
_, err = file.Write([]byte("hello world"))
s.Require().NoError(err)
s.Require().NoError(writer.Close())
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", msgListURL(s.testAccount.ID, s.testConv.ID), &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
attachments, ok := resp["attachments"].([]interface{})
assert.True(s.T(), ok)
assert.Len(s.T(), attachments, 1)
attachment := attachments[0].(map[string]interface{})
assert.Equal(s.T(), "file", attachment["file_type"])
assert.Contains(s.T(), attachment["data_url"], "hello.txt")
var count int64
s.Require().NoError(s.db.Model(&model.Attachment{}).Where("message_id = ?", uint(resp["id"].(float64))).Count(&count).Error)
assert.Equal(s.T(), int64(1), count)
}
func (s *MessageHandlerTestSuite) TestCreate_MissingContent() {
payload := map[string]interface{}{
"content_type": "text",
@@ -355,10 +430,8 @@ func (s *MessageHandlerTestSuite) TestGet_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.True(s.T(), resp["success"].(bool))
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), float64(s.testMessage.ID), data["id"])
assert.Equal(s.T(), "Hello world", data["content"])
assert.Equal(s.T(), float64(s.testMessage.ID), resp["id"])
assert.Equal(s.T(), "Hello world", resp["content"])
}
func (s *MessageHandlerTestSuite) TestGet_NotFound() {
@@ -398,6 +471,43 @@ func (s *MessageHandlerTestSuite) TestUpdate_Success() {
assert.Equal(s.T(), "Updated content", resp["content"])
}
func (s *MessageHandlerTestSuite) TestUpdate_StatusExternalError() {
s.Require().NoError(s.db.Model(s.testInbox).Update("channel_type", "api").Error)
s.testInbox.ChannelType = "api"
payload := map[string]interface{}{
"status": "failed",
"external_error": "provider rejected message",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body))
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{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(s.T(), "failed", resp["status"])
attrs := resp["content_attributes"].(map[string]interface{})
assert.Equal(s.T(), "provider rejected message", attrs["external_error"])
}
func (s *MessageHandlerTestSuite) TestUpdate_StatusForbiddenForNonAPIInbox() {
payload := map[string]interface{}{"status": "delivered"}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusForbidden, w.Code)
}
func (s *MessageHandlerTestSuite) TestUpdate_NotFound() {
payload := map[string]interface{}{
"content": "Updated content",
@@ -416,12 +526,26 @@ func (s *MessageHandlerTestSuite) TestUpdate_NotFound() {
// --- Delete Tests ---
func (s *MessageHandlerTestSuite) TestDelete_Success() {
attachment := &model.Attachment{MessageID: s.testMessage.ID, AccountID: s.testAccount.ID, FileType: "file", FileName: "delete.txt"}
s.Require().NoError(s.db.Create(attachment).Error)
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("DELETE", url, nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(s.T(), "This message was deleted", resp["content"])
attrs := resp["content_attributes"].(map[string]interface{})
assert.Equal(s.T(), true, attrs["deleted"])
_, hasAttachments := resp["attachments"]
assert.False(s.T(), hasAttachments)
var count int64
s.Require().NoError(s.db.Model(&model.Attachment{}).Where("message_id = ?", s.testMessage.ID).Count(&count).Error)
assert.Equal(s.T(), int64(0), count)
}
func (s *MessageHandlerTestSuite) TestDelete_NotFound() {