From 1e3bf47071e7ee911b6a12ebbba6cc9ca01c772b Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 03:07:57 +0800 Subject: [PATCH] feat(crm): align contact merge action --- cmd/route_parity/main.go | 1 + docs/parity/gochat_routes.txt | 3 +- docs/parity/route_parity.md | 3 +- internal/handler/api/v1/contact_handler.go | 2 +- .../api/v1/contact_handler_crud_test.go | 38 +++++++++++++ .../handler/api/v1/contact_merge_handler.go | 18 +++--- .../api/v1/contact_merge_handler_test.go | 29 +++++++--- internal/repository/contact_merge_repo.go | 26 +++++---- internal/router/router.go | 3 +- .../service/contact_merge_service_test.go | 55 ++++++++++++++++++- 10 files changed, 142 insertions(+), 36 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index ba8b6e31..f5ef7697 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -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"}, diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 93b3374e..3c8b735f 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -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 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 8b4f9c15..98e23d9b 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -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 | diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index 953188b2..55f61676 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -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. diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index 70bc5fa4..6b90d5c2 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -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"}}) diff --git a/internal/handler/api/v1/contact_merge_handler.go b/internal/handler/api/v1/contact_merge_handler.go index ebb7d8e2..8a231d7e 100644 --- a/internal/handler/api/v1/contact_merge_handler.go +++ b/internal/handler/api/v1/contact_merge_handler.go @@ -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) -} \ No newline at end of file + c.JSON(http.StatusOK, serializeCRMContact(c.Request.Context(), nil, contact, false)) +} diff --git a/internal/handler/api/v1/contact_merge_handler_test.go b/internal/handler/api/v1/contact_merge_handler_test.go index f06fff0d..fe7ff583 100644 --- a/internal/handler/api/v1/contact_merge_handler_test.go +++ b/internal/handler/api/v1/contact_merge_handler_test.go @@ -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) -} \ No newline at end of file +} diff --git a/internal/repository/contact_merge_repo.go b/internal/repository/contact_merge_repo.go index 1c1fff5f..74087fe0 100644 --- a/internal/repository/contact_merge_repo.go +++ b/internal/repository/contact_merge_repo.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 675d7c38..01edddad 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/service/contact_merge_service_test.go b/internal/service/contact_merge_service_test.go index 28046eb8..64cc6e1e 100644 --- a/internal/service/contact_merge_service_test.go +++ b/internal/service/contact_merge_service_test.go @@ -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 -} \ No newline at end of file + + 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) +}