diff --git a/backend/internal/handler/api/v1/conversation_handler_crud_test.go b/backend/internal/handler/api/v1/conversation_handler_crud_test.go index d31721c5..c60e0466 100644 --- a/backend/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/backend/internal/handler/api/v1/conversation_handler_crud_test.go @@ -799,6 +799,27 @@ func (s *ConversationCrudTestSuite) TestToggleStatus_Success() { assert.Equal(s.T(), "resolved", resp.Payload.CurrentStatus) } +func (s *ConversationCrudTestSuite) TestToggleStatus_SoftDeletedInboxStillUpdatesConversation() { + s.Require().NoError(s.db.Delete(s.testInbox).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "status": "resolved", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.convURL(s.testConv.ID)+"/toggle_status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Require().Equal(http.StatusOK, w.Code, w.Body.String()) + var resp struct { + Payload struct { + CurrentStatus string `json:"current_status"` + } `json:"payload"` + } + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal("resolved", resp.Payload.CurrentStatus) +} + func (s *ConversationCrudTestSuite) TestToggleStatus_NoStatusTogglesLikeChatwoot() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.convURL(s.testConv.ID)+"/toggle_status", nil) diff --git a/backend/internal/repository/conversation_repo.go b/backend/internal/repository/conversation_repo.go index 544d448b..7ec217da 100644 --- a/backend/internal/repository/conversation_repo.go +++ b/backend/internal/repository/conversation_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "strings" "time" @@ -49,14 +50,29 @@ func (r *ConversationRepo) FindByAccountAndID(ctx context.Context, accountID, id } // FindByAccountAndDisplayIDOrID retrieves a conversation using Chatwoot's -// account-scoped display_id route semantics, falling back to primary key for -// legacy GoChat data and tests that predate display_id. +// account-scoped display_id route semantics, while preserving routes emitted +// for legacy rows that still have a NULL/zero display_id. Legacy primary keys +// must win before display_id lookup because the two identifier spaces can +// collide after newer conversations receive account-scoped display IDs. func (r *ConversationRepo) FindByAccountAndDisplayIDOrID(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) { var conversation model.Conversation - err := r.db.WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, routeID).First(&conversation).Error + err := r.db.WithContext(ctx). + Where("account_id = ? AND id = ? AND (display_id IS NULL OR display_id = 0)", accountID, routeID). + First(&conversation).Error if err == nil { return &conversation, nil } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + err = r.db.WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, routeID).First(&conversation).Error + if err == nil { + return &conversation, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } err = r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, routeID).First(&conversation).Error if err != nil { diff --git a/backend/internal/repository/coverage2_test.go b/backend/internal/repository/coverage2_test.go index 8a29d214..c0d9ccfe 100644 --- a/backend/internal/repository/coverage2_test.go +++ b/backend/internal/repository/coverage2_test.go @@ -70,6 +70,26 @@ func TestCov2_ConversationRepo_FindByAccountAndDisplayIDOrID(t *testing.T) { assert.Error(t, err) } +func TestConversationRepo_FindByAccountAndDisplayIDOrID_PrefersLegacyNullDisplayIDPrimaryKey(t *testing.T) { + db := setupTestDB(t) + repo := NewConversationRepo(db) + ctx := context.Background() + account, inbox, contact := createConvAccount(t, db) + + legacy := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(legacy).Error) + require.Nil(t, legacy.DisplayID) + + collidingDisplayID := legacy.ID + modern := &model.Conversation{DisplayID: &collidingDisplayID, AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(modern).Error) + + found, err := repo.FindByAccountAndDisplayIDOrID(ctx, account.ID, legacy.ID) + require.NoError(t, err) + assert.Equal(t, legacy.ID, found.ID) + assert.Nil(t, found.DisplayID) +} + func TestCov2_ConversationRepo_FindByContact(t *testing.T) { db := setupTestDB(t) repo := NewConversationRepo(db) diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index aefcf3df..35440c92 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -644,7 +644,7 @@ func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id ui func (s *ConversationService) persistShangwutongConversationStatus(ctx context.Context, conversation *model.Conversation, previousStatus string) error { var inbox model.Inbox - if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").First(&inbox, conversation.InboxID).Error; err != nil { + if err := s.repo.DB().WithContext(ctx).Unscoped().Select("id", "channel_type").First(&inbox, conversation.InboxID).Error; err != nil { return err } metadata, _ := ctx.Value(shangwutongRequestMetadataKey{}).(shangwutongRequestMetadata) diff --git a/frontend/app/javascript/dashboard/components/buttons/ResolveAction.vue b/frontend/app/javascript/dashboard/components/buttons/ResolveAction.vue index a57bc182..e25a4049 100644 --- a/frontend/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/frontend/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -1,5 +1,6 @@