feat(conversations): finish message mutation parity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -51,6 +51,49 @@ func (r *MessageRepo) FindByConversation(ctx context.Context, conversationID uin
|
||||
return messages, total, err
|
||||
}
|
||||
|
||||
// FindByConversationFinder mirrors Chatwoot's MessageFinder before/after windows.
|
||||
func (r *MessageRepo) FindByConversationFinder(ctx context.Context, conversationID uint, after, before uint, filterInternal bool) ([]model.Message, int64, error) {
|
||||
query := r.db.WithContext(ctx).Model(&model.Message{}).Where("conversation_id = ?", conversationID)
|
||||
if filterInternal {
|
||||
query = query.Where("NOT (private = ? OR message_type = ?)", true, model.MessageTypeActivity)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var messages []model.Message
|
||||
switch {
|
||||
case after != 0 && before != 0:
|
||||
err := query.Where("id >= ? AND id < ?", after, before).Order("created_at ASC, id ASC").Limit(1000).Find(&messages).Error
|
||||
return messages, total, err
|
||||
case before != 0:
|
||||
err := query.Where("id < ?", before).Order("created_at DESC, id DESC").Limit(20).Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
reverseMessages(messages)
|
||||
return messages, total, nil
|
||||
case after != 0:
|
||||
err := query.Where("id > ?", after).Order("created_at ASC, id ASC").Limit(100).Find(&messages).Error
|
||||
return messages, total, err
|
||||
default:
|
||||
err := query.Order("created_at DESC, id DESC").Limit(20).Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
reverseMessages(messages)
|
||||
return messages, total, nil
|
||||
}
|
||||
}
|
||||
|
||||
func reverseMessages(messages []model.Message) {
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
}
|
||||
|
||||
// FindByAccountAndID retrieves a message scoped to an account.
|
||||
func (r *MessageRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.Message, error) {
|
||||
var message model.Message
|
||||
@@ -201,4 +244,4 @@ func (r *MessageRepo) FindLastIncomingByConversation(ctx context.Context, conver
|
||||
return nil, err
|
||||
}
|
||||
return &message, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -108,14 +109,21 @@ func (s *MessageService) Search(ctx context.Context, accountID uint, query strin
|
||||
// CreateMessageRequest is the DTO for creating a message.
|
||||
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/messages_controller.rb #create
|
||||
type CreateMessageRequest struct {
|
||||
ConversationID uint `json:"conversation_id" validate:"required"`
|
||||
Content string `json:"content" validate:"required,min=1"`
|
||||
MessageType string `json:"message_type,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
EchoID string `json:"echo_id,omitempty"`
|
||||
ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"`
|
||||
ConversationID uint `json:"conversation_id" validate:"required"`
|
||||
Content string `json:"content"`
|
||||
MessageType string `json:"message_type,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
EchoID string `json:"echo_id,omitempty"`
|
||||
ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"`
|
||||
Attachments []MessageAttachmentInput `json:"-"`
|
||||
}
|
||||
|
||||
type MessageAttachmentInput struct {
|
||||
FileName string
|
||||
FileSize int
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Create creates a new message.
|
||||
@@ -133,6 +141,9 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Content) == "" && len(req.Attachments) == 0 {
|
||||
return nil, fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
var conversation model.Conversation
|
||||
if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil {
|
||||
@@ -163,7 +174,26 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, message); err != nil {
|
||||
if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(message).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, input := range req.Attachments {
|
||||
attachment := &model.Attachment{
|
||||
MessageID: message.ID,
|
||||
AccountID: accountID,
|
||||
FileType: attachmentFileType(input.ContentType),
|
||||
FileURL: attachmentDataURL(message.ID, input.FileName),
|
||||
ThumbURL: attachmentThumbURL(input.ContentType, message.ID, input.FileName),
|
||||
FileSize: input.FileSize,
|
||||
FileName: input.FileName,
|
||||
}
|
||||
if err := tx.Create(attachment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
applogger.L().Errorf("Failed to create message: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -219,7 +249,9 @@ func validContentType(value string) bool {
|
||||
|
||||
// UpdateMessageRequest is the DTO for updating a message.
|
||||
type UpdateMessageRequest struct {
|
||||
Content string `json:"content,omitempty" validate:"omitempty,min=1"`
|
||||
Content string `json:"content,omitempty" validate:"omitempty,min=1"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ExternalError string `json:"external_error,omitempty"`
|
||||
}
|
||||
|
||||
// Update modifies an existing message.
|
||||
@@ -236,6 +268,16 @@ func (s *MessageService) Update(ctx context.Context, accountID, id uint, req Upd
|
||||
if req.Content != "" {
|
||||
message.Content = req.Content
|
||||
}
|
||||
if req.Status != "" && validMessageStatus(req.Status) {
|
||||
if !s.messageInboxIsAPI(ctx, message.InboxID) {
|
||||
return nil, fmt.Errorf("Message status update is only allowed for API inboxes")
|
||||
}
|
||||
if message.Status == "read" && req.Status == "delivered" {
|
||||
return message, nil
|
||||
}
|
||||
message.Status = req.Status
|
||||
message.ContentAttributes = setMessageExternalError(message.ContentAttributes, req.Status, req.ExternalError)
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, message); err != nil {
|
||||
return nil, err
|
||||
@@ -248,22 +290,47 @@ func (s *MessageService) Update(ctx context.Context, accountID, id uint, req Upd
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// Delete soft-deletes a message and dispatches EventMessageDeleted.
|
||||
func (s *MessageService) Delete(ctx context.Context, accountID, id uint) error {
|
||||
func (s *MessageService) messageInboxIsAPI(ctx context.Context, inboxID uint) bool {
|
||||
var inbox model.Inbox
|
||||
if err := s.repo.DB().WithContext(ctx).First(&inbox, inboxID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(inbox.ChannelType) {
|
||||
case "api", "channel::api":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete marks a message deleted using Chatwoot's visible tombstone payload.
|
||||
func (s *MessageService) Delete(ctx context.Context, accountID, id uint) (*model.Message, error) {
|
||||
message, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repo.Delete(ctx, message.ID); err != nil {
|
||||
return err
|
||||
message.Content = "This message was deleted"
|
||||
message.ContentType = "text"
|
||||
message.ContentAttributes = datatypes.JSON([]byte(`{"deleted":true}`))
|
||||
|
||||
if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(message).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("message_id = ?", message.ID).Delete(&model.Attachment{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Dispatch EventMessageDeleted
|
||||
s.dispatchMessageEvent(ctx, channel.EventMessageDeleted, message)
|
||||
s.deleteMessageIndex(ctx, accountID, message.ID)
|
||||
|
||||
return nil
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates the delivery status of a message and dispatches EventMessageStatusUpdated.
|
||||
@@ -296,6 +363,62 @@ func (s *MessageService) UpdateStatus(ctx context.Context, id uint, status strin
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (s *MessageService) ListByConversationFinder(ctx context.Context, conversationID uint, after, before uint, filterInternal bool) ([]model.Message, int64, error) {
|
||||
return s.repo.FindByConversationFinder(ctx, conversationID, after, before, filterInternal)
|
||||
}
|
||||
|
||||
func validMessageStatus(value string) bool {
|
||||
switch value {
|
||||
case "sent", "delivered", "read", "failed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setMessageExternalError(attrs datatypes.JSON, status, externalError string) datatypes.JSON {
|
||||
obj := map[string]any{}
|
||||
if len(attrs) > 0 {
|
||||
_ = json.Unmarshal(attrs, &obj)
|
||||
}
|
||||
if status == "failed" && strings.TrimSpace(externalError) != "" {
|
||||
obj["external_error"] = externalError
|
||||
} else {
|
||||
delete(obj, "external_error")
|
||||
}
|
||||
bytes, _ := json.Marshal(obj)
|
||||
return datatypes.JSON(bytes)
|
||||
}
|
||||
|
||||
func attachmentFileType(contentType string) string {
|
||||
contentType = strings.ToLower(contentType)
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(contentType, "audio/"):
|
||||
return "audio"
|
||||
case strings.HasPrefix(contentType, "video/"):
|
||||
return "video"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentDataURL(messageID uint, fileName string) string {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/uploads/messages/%d/%s", messageID, fileName)
|
||||
}
|
||||
|
||||
func attachmentThumbURL(contentType string, messageID uint, fileName string) string {
|
||||
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||
return attachmentDataURL(messageID, fileName)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Retry retries a failed message by resetting its delivery status.
|
||||
func (s *MessageService) Retry(ctx context.Context, accountID, id uint) (*model.Message, error) {
|
||||
message, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
||||
|
||||
@@ -393,13 +393,14 @@ func TestMessageService_Delete(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(msg).Error)
|
||||
|
||||
// 正常路径:软删除消息
|
||||
err := svc.Delete(ctx, account.ID, msg.ID)
|
||||
// 正常路径:Chatwoot 删除会保留消息并标记 content_attributes.deleted
|
||||
deleted, err := svc.Delete(ctx, account.ID, msg.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "This message was deleted", deleted.Content)
|
||||
|
||||
// 验证已软删除
|
||||
_, err = svc.GetByAccountAndID(ctx, account.ID, msg.ID)
|
||||
assert.Error(t, err)
|
||||
stored, err := svc.GetByAccountAndID(ctx, account.ID, msg.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"deleted":true}`, string(stored.ContentAttributes))
|
||||
|
||||
// 错误路径:accountID不匹配
|
||||
msg2 := &model.Message{
|
||||
@@ -407,11 +408,11 @@ func TestMessageService_Delete(t *testing.T) {
|
||||
Content: "另一条消息", MessageType: "incoming", ContentType: "text", SenderType: "contact",
|
||||
}
|
||||
require.NoError(t, db.Create(msg2).Error)
|
||||
err = svc.Delete(ctx, 9999, msg2.ID)
|
||||
_, err = svc.Delete(ctx, 9999, msg2.ID)
|
||||
assert.Error(t, err)
|
||||
|
||||
// 错误路径:ID不存在
|
||||
err = svc.Delete(ctx, account.ID, 9999)
|
||||
_, err = svc.Delete(ctx, account.ID, 9999)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -621,4 +622,4 @@ func TestMessageService_Retry(t *testing.T) {
|
||||
_, err := svc.Retry(ctx, 9999, msg.ID)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ func TestMessageService_SearchIndexHooks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
_, err = svc.Update(context.Background(), account.ID, message.ID, UpdateMessageRequest{Content: "updated"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.Delete(context.Background(), account.ID, message.ID))
|
||||
_, err = svc.Delete(context.Background(), account.ID, message.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"message", "message"}, indexer.indexed)
|
||||
assert.Equal(t, []string{"message"}, indexer.deleted)
|
||||
|
||||
@@ -53,6 +53,7 @@ func setupServiceTestDB(t *testing.T) *gorm.DB {
|
||||
&model.Conversation{},
|
||||
&model.ConversationParticipant{},
|
||||
&model.Message{},
|
||||
&model.Attachment{},
|
||||
&model.InboxMember{},
|
||||
&model.Notification{},
|
||||
&model.NotificationPreference{},
|
||||
@@ -372,4 +373,4 @@ func createTestMessage(t *testing.T, db *gorm.DB, accountID, inboxID, conversati
|
||||
func skipIfSQLite(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Skip("Skipping: this test requires PostgreSQL (ILIKE / trigram / pgvector etc.)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user