feat(crm): align shared attachment payloads
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -186,6 +186,7 @@ GET /api/v1/accounts/:account_id/companies/search
|
||||
GET /api/v1/accounts/:account_id/contact_inboxes/filter
|
||||
GET /api/v1/accounts/:account_id/contacts/
|
||||
GET /api/v1/accounts/:account_id/contacts/:contact_id
|
||||
GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments
|
||||
GET /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes
|
||||
GET /api/v1/accounts/:account_id/contacts/:contact_id/contactable_inboxes
|
||||
GET /api/v1/accounts/:account_id/contacts/:contact_id/conversations
|
||||
@@ -712,6 +713,7 @@ POST /api/v1/widget/integrations/dyte/add_participant_to_meeting
|
||||
POST /api/v1/widget/labels
|
||||
POST /api/v1/widget/messages
|
||||
POST /api/v2/accounts/
|
||||
POST /auth/confirmation
|
||||
POST /auth/password
|
||||
POST /auth/sign_in
|
||||
POST /platform/api/v1/accounts
|
||||
@@ -813,6 +815,7 @@ PUT /api/v1/accounts/:account_id/teams/:team_id
|
||||
PUT /api/v1/accounts/:account_id/web_widgets/offline_messages/:offline_message_id/convert
|
||||
PUT /api/v1/accounts/:account_id/web_widgets/offline_messages/:offline_message_id/dismiss
|
||||
PUT /api/v1/accounts/:account_id/webhooks/:webhook_id
|
||||
PUT /api/v1/auth/reset_password
|
||||
PUT /api/v1/ldap/config
|
||||
PUT /api/v1/notifications/:id
|
||||
PUT /api/v1/oidc/config
|
||||
@@ -822,6 +825,7 @@ PUT /api/v1/profile/set_active_account
|
||||
PUT /api/v1/widget/contact
|
||||
PUT /api/v1/widget/direct_uploads/:upload_uuid
|
||||
PUT /api/v1/widget/messages/:message_id
|
||||
PUT /auth/password
|
||||
PUT /platform/api/v1/agent_bots/:id
|
||||
PUT /platform/api/v1/agent_bots/:id/avatar
|
||||
PUT /platform/api/v1/apps/:id
|
||||
@@ -831,4 +835,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 833
|
||||
TOTAL: 837
|
||||
|
||||
@@ -832,6 +832,36 @@ func (h *ContactHandler) ContactableInboxes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload})
|
||||
}
|
||||
|
||||
// ListAttachments returns a contact's shared files across all visible conversations.
|
||||
// GET /api/v1/accounts/:id/contacts/:contact_id/attachments
|
||||
// Reference: Chatwoot Api::V1::Accounts::Contacts::AttachmentsController#index
|
||||
func (h *ContactHandler) ListAttachments(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
contactID, err := parseUintParam(c, "contact_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
||||
return
|
||||
}
|
||||
|
||||
p := pagination.Parse(c)
|
||||
attachments, total, svcErr := h.svc.ListAttachments(c.Request.Context(), accountID, contactID, p.Offset, p.PerPage)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact attachments"})
|
||||
return
|
||||
}
|
||||
|
||||
payload := make([]any, 0, len(attachments))
|
||||
for i := range attachments {
|
||||
payload = append(payload, serializeAttachmentWithConversation(c.Request.Context(), h.svc.DB(), &attachments[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"meta": gin.H{"total_count": total}, "payload": payload})
|
||||
}
|
||||
|
||||
// DeleteCustomAttributes removes all custom attributes from a contact.
|
||||
// DELETE /api/v1/accounts/:id/contacts/:contact_id/custom_attributes
|
||||
// Reference: Chatwoot contacts#destroy_custom_attributes
|
||||
|
||||
@@ -96,6 +96,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
||||
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Delete)
|
||||
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/avatar", s.handler.DeleteAvatar)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/attachments", s.handler.ListAttachments)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels)
|
||||
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes)
|
||||
@@ -578,6 +579,39 @@ func (s *ContactHandlerCRUDTestSuite) TestDeleteAvatar_Success() {
|
||||
s.Equal("", found.AvatarURL)
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsReturnsChatwootPayload() {
|
||||
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Shared Files", ChannelType: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(inbox).Error)
|
||||
displayID := uint(42)
|
||||
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(conversation).Error)
|
||||
message := &model.Message{AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &s.contact.ID, SenderType: "contact", Content: "image", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
||||
s.Require().NoError(s.db.Create(message).Error)
|
||||
attachment := &model.Attachment{AccountID: s.account.ID, MessageID: message.ID, FileType: "image", FileURL: "https://cdn.example.com/image.png", ThumbURL: "https://cdn.example.com/thumb.png", FileName: "image.png", FileSize: 1234, Width: 640, Height: 480}
|
||||
s.Require().NoError(s.db.Create(attachment).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/attachments", s.account.ID, s.contact.ID), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
||||
var resp map[string]any
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
meta := resp["meta"].(map[string]any)
|
||||
s.Equal(float64(1), meta["total_count"])
|
||||
payload := resp["payload"].([]any)
|
||||
s.Len(payload, 1)
|
||||
item := payload[0].(map[string]any)
|
||||
s.Equal(float64(attachment.ID), item["id"])
|
||||
s.Equal(float64(message.ID), item["message_id"])
|
||||
s.Equal("image", item["file_type"])
|
||||
s.Equal("https://cdn.example.com/image.png", item["data_url"])
|
||||
s.Equal("png", item["extension"])
|
||||
s.Equal(float64(displayID), item["conversation_id"])
|
||||
s.Contains(item, "created_at")
|
||||
s.Contains(item, "sender")
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestMerge_ChatwootActionsPathReturnsRawContact() {
|
||||
base := &model.Contact{AccountID: s.account.ID, Name: "Base Contact", Email: "base@example.com"}
|
||||
mergee := &model.Contact{AccountID: s.account.ID, Name: "Mergee Contact", PhoneNumber: "+12212345"}
|
||||
|
||||
@@ -764,13 +764,17 @@ func (h *ConversationHandler) ListAttachments(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attachments, _, svcErr := h.messageSvc.ListAttachments(c.Request.Context(), accountID, conversation.ID, p.Offset, p.PerPage)
|
||||
attachments, total, svcErr := h.messageSvc.ListAttachments(c.Request.Context(), accountID, conversation.ID, p.Offset, p.PerPage)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"payload": attachments})
|
||||
payload := make([]any, 0, len(attachments))
|
||||
for i := range attachments {
|
||||
payload = append(payload, serializeAttachment(c.Request.Context(), h.conversationSvc.DB(), &attachments[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"meta": gin.H{"total_count": total}, "payload": payload})
|
||||
}
|
||||
|
||||
// ToggleTyping toggles the typing status for an agent in a conversation.
|
||||
|
||||
@@ -68,6 +68,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() {
|
||||
&model.Conversation{},
|
||||
&model.ConversationParticipant{},
|
||||
&model.Message{},
|
||||
&model.Attachment{},
|
||||
&model.InboxMember{},
|
||||
&model.Tag{},
|
||||
&model.ConversationLabel{},
|
||||
@@ -134,6 +135,7 @@ func (s *ConversationHandlerTestSuite) TearDownSuite() {
|
||||
}
|
||||
|
||||
func (s *ConversationHandlerTestSuite) TearDownTest() {
|
||||
s.db.Exec("DELETE FROM attachments")
|
||||
s.db.Exec("DELETE FROM conversations")
|
||||
s.db.Exec("DELETE FROM contact_inboxes")
|
||||
s.db.Exec("DELETE FROM contacts")
|
||||
@@ -420,6 +422,31 @@ func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidConversationID
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func (s *ConversationHandlerTestSuite) TestListAttachmentsReturnsChatwootPayload() {
|
||||
message := &model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "file", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
||||
s.Require().NoError(s.db.Create(message).Error)
|
||||
attachment := &model.Attachment{AccountID: s.testAccount.ID, MessageID: message.ID, FileType: "file", FileURL: "https://cdn.example.com/report.pdf", FileName: "report.pdf", FileSize: 2048}
|
||||
s.Require().NoError(s.db.Create(attachment).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments", s.accountURL(), s.testConv.ID), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
|
||||
var resp map[string]any
|
||||
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), float64(1), resp["meta"].(map[string]any)["total_count"])
|
||||
payload := resp["payload"].([]any)
|
||||
assert.Len(s.T(), payload, 1)
|
||||
item := payload[0].(map[string]any)
|
||||
assert.Equal(s.T(), float64(attachment.ID), item["id"])
|
||||
assert.Equal(s.T(), float64(message.ID), item["message_id"])
|
||||
assert.Equal(s.T(), "file", item["file_type"])
|
||||
assert.Equal(s.T(), "https://cdn.example.com/report.pdf", item["data_url"])
|
||||
assert.Equal(s.T(), "pdf", item["extension"])
|
||||
assert.Contains(s.T(), item, "created_at")
|
||||
}
|
||||
|
||||
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidAccountID() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/toggle_typing", bytes.NewBufferString(`{"typing_status":"on"}`))
|
||||
|
||||
@@ -304,17 +304,17 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message,
|
||||
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]))
|
||||
payload.Attachments = append(payload.Attachments, serializeAttachment(ctx, db, &attachments[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeAttachment(attachment *model.Attachment) map[string]any {
|
||||
func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any {
|
||||
extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".")
|
||||
dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL)
|
||||
return map[string]any{
|
||||
payload := map[string]any{
|
||||
"id": attachment.ID,
|
||||
"message_id": attachment.MessageID,
|
||||
"file_type": attachment.FileType,
|
||||
@@ -326,6 +326,52 @@ func serializeAttachment(attachment *model.Attachment) map[string]any {
|
||||
"width": attachment.Width,
|
||||
"height": attachment.Height,
|
||||
}
|
||||
|
||||
message := attachment.Message
|
||||
if message.ID == 0 && db != nil && attachment.MessageID != 0 {
|
||||
_ = db.WithContext(ctx).First(&message, attachment.MessageID).Error
|
||||
}
|
||||
if !message.CreatedAt.IsZero() {
|
||||
payload["created_at"] = message.CreatedAt.Unix()
|
||||
}
|
||||
if sender := serializeMessageSender(ctx, db, &message); sender != nil {
|
||||
payload["sender"] = sender
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeAttachmentWithConversation(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any {
|
||||
payload := serializeAttachment(ctx, db, attachment)
|
||||
message := attachment.Message
|
||||
if message.ID == 0 && db != nil && attachment.MessageID != 0 {
|
||||
_ = db.WithContext(ctx).First(&message, attachment.MessageID).Error
|
||||
}
|
||||
if db != nil && message.ConversationID != 0 {
|
||||
var conversation model.Conversation
|
||||
if err := db.WithContext(ctx).First(&conversation, message.ConversationID).Error; err == nil {
|
||||
payload["conversation_id"] = conversationDisplayID(&conversation)
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeMessageSender(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any {
|
||||
if db == nil || message == nil || message.SenderID == nil || *message.SenderID == 0 {
|
||||
return nil
|
||||
}
|
||||
senderType := strings.ToLower(message.SenderType)
|
||||
if senderType == "contact" {
|
||||
var contact model.Contact
|
||||
if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil {
|
||||
return serializeContact(&contact)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var user model.User
|
||||
if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil {
|
||||
return serializeUser(&user, message.AccountID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializeContact(contact *model.Contact) map[string]any {
|
||||
|
||||
@@ -1004,9 +1004,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
contacts.DELETE("/:contact_id", h.Contact.Delete)
|
||||
contacts.DELETE("/:contact_id/avatar", h.Contact.DeleteAvatar)
|
||||
|
||||
// M4 G3: Contact extension routes (active, export, import, contactable_inboxes, custom_attributes)
|
||||
// M4 G3: Contact extension routes (active, export, import, contactable_inboxes, attachments, custom_attributes)
|
||||
contacts.GET("/:contact_id/conversations", h.Contact.ListConversations)
|
||||
contacts.GET("/:contact_id/contactable_inboxes", h.Contact.ContactableInboxes)
|
||||
contacts.GET("/:contact_id/attachments", h.Contact.ListAttachments)
|
||||
contacts.GET("/:contact_id/labels", h.Contact.ListLabels)
|
||||
contacts.GET("/:contact_id/labels/", h.Contact.ListLabels)
|
||||
contacts.POST("/:contact_id/labels", h.Contact.UpdateLabels)
|
||||
|
||||
@@ -1230,3 +1230,35 @@ func (s *ContactService) GetContactableInboxes(ctx context.Context, accountID, c
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListAttachments returns a contact's message attachments across account-scoped conversations.
|
||||
// Reference: Chatwoot Api::V1::Accounts::Contacts::AttachmentsController#index.
|
||||
func (s *ContactService) ListAttachments(ctx context.Context, accountID, contactID uint, offset, limit int) ([]model.Attachment, int64, error) {
|
||||
if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil {
|
||||
return nil, 0, errors.New("contact not found")
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
db := s.repo.DB()
|
||||
scope := db.WithContext(ctx).
|
||||
Where("account_id = ? AND message_id IN (SELECT messages.id FROM messages JOIN conversations ON conversations.id = messages.conversation_id WHERE conversations.account_id = ? AND conversations.contact_id = ?)", accountID, accountID, contactID)
|
||||
|
||||
var total int64
|
||||
if err := scope.Model(&model.Attachment{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var attachments []model.Attachment
|
||||
if err := scope.
|
||||
Preload("Message").
|
||||
Order("created_at DESC, id DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&attachments).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return attachments, total, nil
|
||||
}
|
||||
|
||||
@@ -529,23 +529,22 @@ func (s *MessageService) ListAttachments(ctx context.Context, accountID, convers
|
||||
var total int64
|
||||
|
||||
db := s.repo.DB()
|
||||
err := db.WithContext(ctx).
|
||||
Where("account_id = ? AND message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", accountID, conversationID).
|
||||
Order("id ASC").
|
||||
Find(&attachments).Error
|
||||
if err != nil {
|
||||
scope := db.WithContext(ctx).
|
||||
Where("account_id = ? AND message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", accountID, conversationID)
|
||||
if err := scope.Model(&model.Attachment{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if err := scope.
|
||||
Preload("Message").
|
||||
Order("created_at DESC, id DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&attachments).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
total = int64(len(attachments))
|
||||
start := offset
|
||||
if start > len(attachments) {
|
||||
start = len(attachments)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(attachments) {
|
||||
end = len(attachments)
|
||||
}
|
||||
|
||||
return attachments[start:end], total, nil
|
||||
return attachments, total, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user