feat(crm): align contact merge action

This commit is contained in:
2026-06-05 03:07:57 +08:00
parent c7f2b43b4a
commit 1e3bf47071
10 changed files with 142 additions and 36 deletions
+1
View File
@@ -28,6 +28,7 @@ var criticalRoutes = []route{
{Method: "PUT", Path: "/api/v1/accounts/:account_id", Controller: "api/v1/accounts#update", Source: "routes.rb:47"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/update_active_at", Controller: "api/v1/accounts#update_active_at", Source: "routes.rb:49"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/cache_keys", Controller: "api/v1/accounts#cache_keys", Source: "routes.rb:50"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/actions/contact_merge", Controller: "api/v1/accounts/actions/contact_merges#create", Source: "routes.rb:55"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/agents", Controller: "api/v1/accounts/agents#index", Source: "routes.rb:59"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/agents", Controller: "api/v1/accounts/agents#create", Source: "routes.rb:59"},
+2 -1
View File
@@ -476,6 +476,7 @@ PATCH /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
PATCH /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
PATCH /widget/contact
POST /api/v1/accounts/
POST /api/v1/accounts/:account_id/actions/contact_merge
POST /api/v1/accounts/:account_id/agent_bot_inboxes/
POST /api/v1/accounts/:account_id/agent_bots/
POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/
@@ -815,4 +816,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: 817
TOTAL: 818
+2 -1
View File
@@ -7,7 +7,7 @@ Generated from:
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
Summary: 261 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 261 tracked critical routes.
Summary: 262 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 262 tracked critical routes.
## Missing Critical Routes
@@ -183,6 +183,7 @@ These routes exist with equivalent method and path shape but different parameter
| PATCH | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#update` | `routes.rb:300` | exact |
| PATCH | `/api/v1/widget/contact/set_user` | `/api/v1/widget/contact/set_user` | `api/v1/widget/contact#set_user` | `routes.rb:461` | exact |
| POST | `/api/v1/accounts/` | `/api/v1/accounts/` | `api/v1/accounts#create` | `routes.rb:47` | exact |
| POST | `/api/v1/accounts/:account_id/actions/contact_merge` | `/api/v1/accounts/:account_id/actions/contact_merge` | `api/v1/accounts/actions/contact_merges#create` | `routes.rb:55` | exact |
| POST | `/api/v1/accounts/:account_id/agents` | `/api/v1/accounts/:account_id/agents` | `api/v1/accounts/agents#create` | `routes.rb:59` | exact |
| POST | `/api/v1/accounts/:account_id/agents/bulk_create` | `/api/v1/accounts/:account_id/agents/bulk_create` | `api/v1/accounts/agents#bulk_create` | `routes.rb:60` | exact |
| POST | `/api/v1/accounts/:account_id/assignment_policies/` | `/api/v1/accounts/:account_id/assignment_policies/` | `api/v1/accounts/assignment_policies#create` | `routes.rb:306` | exact |
+1 -1
View File
@@ -818,7 +818,7 @@ func (h *ContactHandler) Merge(c *gin.Context) {
return
}
response.OK(c, result)
c.JSON(http.StatusOK, serializeCRMContact(c.Request.Context(), h.svc.DB(), result, false))
}
// Filter retrieves contacts matching advanced filter criteria.
@@ -52,6 +52,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
&model.Tag{},
&model.ContactLabel{},
&model.Conversation{},
&model.Message{},
&model.ContactInbox{},
&model.InboxMember{},
&model.ContactNote{},
@@ -92,6 +93,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes)
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes)
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote)
s.router.POST("/api/v1/accounts/:id/actions/contact_merge", s.handler.Merge)
// Create test data
s.account = &model.Account{Name: "Test Account", Locale: "en", Status: "active"}
@@ -118,6 +120,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupTest() {
s.db.Exec("DELETE FROM contact_notes")
s.db.Exec("DELETE FROM contact_labels")
s.db.Exec("DELETE FROM tags")
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM notes")
s.db.Exec("DELETE FROM contact_inboxes")
s.db.Exec("DELETE FROM conversations")
@@ -559,6 +562,41 @@ func (s *ContactHandlerCRUDTestSuite) TestDeleteAvatar_Success() {
s.Equal("", found.AvatarURL)
}
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"}
s.Require().NoError(s.db.Create(base).Error)
s.Require().NoError(s.db.Create(mergee).Error)
s.Require().NoError(s.db.Create(&model.Conversation{AccountID: s.account.ID, InboxID: 1, ContactID: mergee.ID, Status: "open", ChannelType: "Channel::WebWidget", Channel: "web_widget"}).Error)
s.Require().NoError(s.db.Create(&model.Note{AccountID: s.account.ID, ContactID: mergee.ID, Content: "mergee note", UserID: &s.user.ID}).Error)
bodyBytes, _ := json.Marshal(map[string]uint{"base_contact_id": base.ID, "mergee_contact_id": mergee.ID})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST",
fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID),
bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal(float64(base.ID), resp["id"])
s.Equal("Base Contact", resp["name"])
s.Equal("base@example.com", resp["email"])
s.Equal("+12212345", resp["phone_number"])
s.NotContains(resp, "payload")
s.NotContains(resp, "success")
var count int64
s.db.Model(&model.Contact{}).Where("id = ?", mergee.ID).Count(&count)
s.Equal(int64(0), count)
s.db.Model(&model.Conversation{}).Where("contact_id = ?", base.ID).Count(&count)
s.Equal(int64(1), count)
s.db.Model(&model.Note{}).Where("contact_id = ?", base.ID).Count(&count)
s.Equal(int64(1), count)
}
func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() {
bodyBytes, _ := json.Marshal(map[string]interface{}{"labels": []string{"vip", "trial"}})
@@ -12,6 +12,7 @@ package v1
// Chatwoot's response: returns the base contact after merge on success, 200 OK
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -32,12 +33,13 @@ func NewContactMergeHandler(mergeService *service.ContactMergeService) *ContactM
// Create merges the mergee contact into the base contact.
// Reference: Chatwoot `def create`
// contact_merge_action = ContactMergeAction.new(
// account: Current.account,
// base_contact: @base_contact,
// mergee_contact: @mergee_contact
// )
// contact_merge_action.perform
//
// contact_merge_action = ContactMergeAction.new(
// account: Current.account,
// base_contact: @base_contact,
// mergee_contact: @mergee_contact
// )
// contact_merge_action.perform
//
// Chatwoot returns the base contact on success with 200 OK.
func (h *ContactMergeHandler) Create(c *gin.Context) {
@@ -75,5 +77,5 @@ func (h *ContactMergeHandler) Create(c *gin.Context) {
return
}
response.OK(c, contact)
}
c.JSON(http.StatusOK, serializeCRMContact(c.Request.Context(), nil, contact, false))
}
@@ -24,7 +24,7 @@ type ContactMergeHandlerTestSuite struct {
db *gorm.DB
handler *ContactMergeHandler
account *model.Account
account *model.Account
contact1 *model.Contact
contact2 *model.Contact
}
@@ -63,31 +63,42 @@ func TestContactMergeHandlerSuite(t *testing.T) {
func (s *ContactMergeHandlerTestSuite) TestCreate_Success() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/actions/contact_merges", func(c *gin.Context) {
r.POST("/api/v1/accounts/:account_id/actions/contact_merge", func(c *gin.Context) {
c.Set("account_id", float64(s.account.ID))
c.Next()
}, s.handler.Create)
body := map[string]uint{"base_contact_id": s.contact1.ID, "mergee_contact_id": s.contact2.ID}
base := &model.Contact{Name: "merge-base", Email: "base@example.com", AccountID: s.account.ID}
mergee := &model.Contact{Name: "mergee-contact", PhoneNumber: "+12212345", AccountID: s.account.ID}
s.Require().NoError(s.db.Create(base).Error)
s.Require().NoError(s.db.Create(mergee).Error)
body := map[string]uint{"base_contact_id": base.ID, "mergee_contact_id": mergee.ID}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merges", s.account.ID), bytes.NewBuffer(b))
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID), bytes.NewBuffer(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(s.T(), float64(base.ID), payload["id"])
assert.Equal(s.T(), "base@example.com", payload["email"])
assert.NotContains(s.T(), payload, "success")
assert.NotContains(s.T(), payload, "data")
}
func (s *ContactMergeHandlerTestSuite) TestCreate_BadRequest_NoBody() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/actions/contact_merges", func(c *gin.Context) {
r.POST("/api/v1/accounts/:account_id/actions/contact_merge", func(c *gin.Context) {
c.Set("account_id", float64(s.account.ID))
c.Next()
}, s.handler.Create)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merges", s.account.ID), nil)
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID), nil)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -96,15 +107,15 @@ func (s *ContactMergeHandlerTestSuite) TestCreate_BadRequest_NoBody() {
func (s *ContactMergeHandlerTestSuite) TestCreate_BadRequest_InvalidAccountID() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/actions/contact_merges", s.handler.Create)
r.POST("/api/v1/accounts/:account_id/actions/contact_merge", s.handler.Create)
body := map[string]uint{"base_contact_id": s.contact1.ID, "mergee_contact_id": s.contact2.ID}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/actions/contact_merges", bytes.NewBuffer(b))
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/actions/contact_merge", bytes.NewBuffer(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
}
+14 -12
View File
@@ -21,9 +21,9 @@ import (
)
var (
ErrContactNotInAccount = errors.New("contact does not belong to the account")
ErrSameContactMerge = errors.New("cannot merge the same contact")
ErrBaseContactNotFound = errors.New("base contact not found")
ErrContactNotInAccount = errors.New("contact does not belong to the account")
ErrSameContactMerge = errors.New("cannot merge the same contact")
ErrBaseContactNotFound = errors.New("base contact not found")
ErrMergeeContactNotFound = errors.New("mergee contact not found")
)
@@ -73,10 +73,11 @@ func (r *ContactMergeRepo) MergeConversations(baseContactID, mergeeContactID uin
// MergeMessages moves all messages where sender was the mergee contact to base contact.
// Reference: Chatwoot `Message.where(sender: @mergee_contact).update(sender: @base_contact)`
// In our model, messages have sender_id and sender_type. For contact senders, sender_type = "Contact".
// In our model, older rows may store either Chatwoot polymorphic "Contact" or
// local lowercase "contact". Merge both so legacy messages move with the contact.
func (r *ContactMergeRepo) MergeMessages(baseContactID, mergeeContactID uint) error {
return r.db.Model(&model.Message{}).
Where("sender_id = ? AND sender_type = ?", mergeeContactID, "Contact").
Where("sender_id = ? AND sender_type IN ?", mergeeContactID, []string{"Contact", "contact"}).
Update("sender_id", baseContactID).Error
}
@@ -100,12 +101,13 @@ func (r *ContactMergeRepo) MergeContactNotes(baseContactID, mergeeContactID, acc
// MergeAttributesAndRemoveMergee merges attributes from mergee into base, then destroys mergee.
// 1:1 Chatwoot: merge_and_remove_mergee_contact
// Logic:
// mergable_attribute_keys = [identifier, name, email, phone_number, additional_attributes, custom_attributes]
// base_attrs = base_contact.attributes.slice(keys).compact_blank
// mergee_attrs = mergee_contact.attributes.slice(keys).compact_blank
// merged_attrs = mergee_attrs.deep_merge(base_attrs) // base takes precedence
// mergee.destroy!
// base_contact.update!(merged_attrs)
//
// mergable_attribute_keys = [identifier, name, email, phone_number, additional_attributes, custom_attributes]
// base_attrs = base_contact.attributes.slice(keys).compact_blank
// mergee_attrs = mergee_contact.attributes.slice(keys).compact_blank
// merged_attrs = mergee_attrs.deep_merge(base_attrs) // base takes precedence
// mergee.destroy!
// base_contact.update!(merged_attrs)
func (r *ContactMergeRepo) MergeAttributesAndRemoveMergee(baseContactID, mergeeContactID, accountID uint) error {
var baseContact, mergeeContact model.Contact
if err := r.db.Where("id = ? AND account_id = ?", baseContactID, accountID).First(&baseContact).Error; err != nil {
@@ -211,4 +213,4 @@ func (r *ContactMergeRepo) GetBaseContact(accountID, baseContactID uint) (*model
return nil, err
}
return &contact, nil
}
}
+2 -1
View File
@@ -704,7 +704,8 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Contact merge (ref: Chatwoot ContactsController #merge)
// POST /api/v1/accounts/:account_id/contacts/merge — merge two contacts
// Also: POST /api/v1/accounts/:account_id/contact_merge — Chatwoot resource path
// Also: POST /api/v1/accounts/:account_id/actions/contact_merge — Chatwoot resource path
accountScoped.POST("/actions/contact_merge", h.Contact.Merge)
accountScoped.POST("/contacts/merge", h.Contact.Merge)
accountScoped.POST("/contact_merge", h.Contact.Merge)
+52 -3
View File
@@ -1,12 +1,14 @@
package service
import (
"encoding/json"
"testing"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -59,17 +61,64 @@ func (s *ContactMergeServiceTestSuite) TestMerge_BaseNotFound() {
}
func (s *ContactMergeServiceTestSuite) TestMerge_Success() {
base := &model.Contact{Base: model.Base{}, AccountID: 1, Name: "base"}
mergee := &model.Contact{Base: model.Base{}, AccountID: 1, Name: "mergee"}
base := &model.Contact{
Base: model.Base{},
AccountID: 1,
Name: "base",
Email: "base@example.com",
Identifier: "base-id",
AdditionalAttributes: datatypes.JSON(`{"city":"base","nested":{"keep":"base"}}`),
CustomAttributes: datatypes.JSON(`{"tier":"gold","base_only":"yes"}`),
}
mergee := &model.Contact{
Base: model.Base{},
AccountID: 1,
Name: "mergee",
Email: "mergee@example.com",
PhoneNumber: "+12212345",
AdditionalAttributes: datatypes.JSON(`{"city":"mergee","mergee_only":"yes","nested":{"new":"mergee"}}`),
CustomAttributes: datatypes.JSON(`{"tier":"silver","mergee_only":"yes"}`),
}
s.db.Create(base)
s.db.Create(mergee)
s.db.Create(&model.Conversation{AccountID: 1, InboxID: 1, ContactID: mergee.ID, Status: "open", ChannelType: "Channel::WebWidget", Channel: "web_widget"})
s.db.Create(&model.ContactInbox{ContactID: mergee.ID, InboxID: 1, SourceID: "source-mergee"})
s.db.Create(&model.Note{AccountID: 1, ContactID: mergee.ID, Content: "mergee note"})
s.db.Create(&model.Message{AccountID: 1, InboxID: 1, ConversationID: 1, SenderID: &mergee.ID, SenderType: "contact", Content: "lowercase sender", ContentType: "text"})
s.db.Create(&model.Message{AccountID: 1, InboxID: 1, ConversationID: 1, SenderID: &mergee.ID, SenderType: string(model.SenderTypeContact), Content: "polymorphic sender", ContentType: "text"})
result, err := s.svc.Merge(1, base.ID, mergee.ID)
assert.NoError(s.T(), err)
assert.Equal(s.T(), "base", result.Name)
assert.Equal(s.T(), "base@example.com", result.Email)
assert.Equal(s.T(), "base-id", result.Identifier)
assert.Equal(s.T(), "+12212345", result.PhoneNumber)
var attrs map[string]any
assert.NoError(s.T(), json.Unmarshal(result.CustomAttributes, &attrs))
assert.Equal(s.T(), "gold", attrs["tier"])
assert.Equal(s.T(), "yes", attrs["base_only"])
assert.Equal(s.T(), "yes", attrs["mergee_only"])
var additional map[string]any
assert.NoError(s.T(), json.Unmarshal(result.AdditionalAttributes, &additional))
assert.Equal(s.T(), "base", additional["city"])
assert.Equal(s.T(), "yes", additional["mergee_only"])
nested := additional["nested"].(map[string]any)
assert.Equal(s.T(), "base", nested["keep"])
assert.Equal(s.T(), "mergee", nested["new"])
// Verify mergee is soft-deleted — GORM Delete sets deleted_at
var count int64
s.db.Model(&model.Contact{}).Where("id = ?", mergee.ID).Count(&count)
assert.Equal(s.T(), int64(0), count) // GORM soft-delete: filtered out by default
}
s.db.Model(&model.Conversation{}).Where("contact_id = ?", base.ID).Count(&count)
assert.Equal(s.T(), int64(1), count)
s.db.Model(&model.ContactInbox{}).Where("contact_id = ?", base.ID).Count(&count)
assert.Equal(s.T(), int64(1), count)
s.db.Model(&model.Note{}).Where("contact_id = ?", base.ID).Count(&count)
assert.Equal(s.T(), int64(1), count)
s.db.Model(&model.Message{}).Where("sender_id = ?", base.ID).Count(&count)
assert.Equal(s.T(), int64(2), count)
}