feat(conversations): align mute semantics

This commit is contained in:
2026-06-07 11:20:12 +08:00
parent 148201905a
commit 6b6289f896
7 changed files with 194 additions and 30 deletions
+20 -6
View File
@@ -395,13 +395,27 @@ func (s *ActionService) handleSendWebhookEvent(ctx context.Context, accountID, c
}
// handleMuteConversation mutes notifications for the conversation.
// Reference: Chatwoot mute_conversation action — marks conversation as muted
// Reference: Chatwoot ActionService#mute_conversation delegates to Conversation#mute!.
func (s *ActionService) handleMuteConversation(ctx context.Context, accountID, conversationID uint) error {
muted := &ConversationMute{
ConversationID: conversationID,
AccountID: accountID,
}
return s.db.DB().WithContext(ctx).Where(muted).FirstOrCreate(muted).Error
db := s.db.DB().WithContext(ctx)
return db.Transaction(func(tx *gorm.DB) error {
var conversation model.Conversation
if err := tx.Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil {
return err
}
if conversation.ContactID != 0 {
if err := tx.Model(&model.Contact{}).
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
Update("blocked", true).Error; err != nil {
return err
}
}
return tx.Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversationID, accountID).
Updates(map[string]any{"status": string(model.ConversationStatusResolved), "muted": true}).Error
})
}
// handleChangeStatus changes the conversation status.
@@ -343,6 +343,49 @@ func TestActionService_AddSla_AttachesPolicyAndAppliedSLA(t *testing.T) {
}
}
func TestActionService_MuteConversation_BlocksContactAndResolvesConversation(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, userID := seedTestAccount(db, t)
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
_, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
ActionName: "mute_conversation",
}, ActionSourceAutomation, userID)
if err != nil {
t.Fatalf("mute_conversation failed: %v", err)
}
var conversation model.Conversation
if err := db.First(&conversation, conversationID).Error; err != nil {
t.Fatalf("failed to load conversation: %v", err)
}
if conversation.Status != string(model.ConversationStatusResolved) {
t.Fatalf("expected conversation status resolved, got %s", conversation.Status)
}
if !conversation.Muted {
t.Fatal("expected conversation muted flag to be true")
}
var contact model.Contact
if err := db.First(&contact, contactID).Error; err != nil {
t.Fatalf("failed to load contact: %v", err)
}
if !contact.Blocked {
t.Fatal("expected contact blocked flag to be true")
}
var legacyMuteCount int64
if err := db.Model(&ConversationMute{}).Where("conversation_id = ?", conversationID).Count(&legacyMuteCount).Error; err != nil {
t.Fatalf("failed to count legacy mute rows: %v", err)
}
if legacyMuteCount != 0 {
t.Fatalf("expected no legacy conversation_mutes row, got %d", legacyMuteCount)
}
}
func TestAutomationRuleService_MatchAndExecute_RecordsEmailTranscriptFailureMetadata(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
@@ -810,6 +810,24 @@ func (s *ConversationCrudTestSuite) TestMute_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
var conversation model.Conversation
s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error)
assert.Equal(s.T(), string(model.ConversationStatusResolved), conversation.Status)
assert.True(s.T(), conversation.Muted)
var contact model.Contact
s.Require().NoError(s.db.First(&contact, s.testContact.ID).Error)
assert.True(s.T(), contact.Blocked)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", s.convURL(s.testConv.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), true, resp["muted"])
assert.Equal(s.T(), string(model.ConversationStatusResolved), resp["status"])
}
func (s *ConversationCrudTestSuite) TestMute_InvalidAccountID() {
@@ -849,6 +867,15 @@ func (s *ConversationCrudTestSuite) TestUnmute_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
var conversation model.Conversation
s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error)
assert.Equal(s.T(), string(model.ConversationStatusResolved), conversation.Status)
assert.False(s.T(), conversation.Muted)
var contact model.Contact
s.Require().NoError(s.db.First(&contact, s.testContact.ID).Error)
assert.False(s.T(), contact.Blocked)
}
func (s *ConversationCrudTestSuite) TestUnmute_InvalidAccountID() {
@@ -238,7 +238,7 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model
CustomAttributes: jsonObject(conversation.CustomAttributes),
InboxID: conversation.InboxID,
Labels: labelList(conversation.Labels),
Muted: conversation.Muted,
Muted: conversationMuted(ctx, db, conversation),
SnoozedUntil: conversation.SnoozedUntil,
Status: conversation.Status,
CreatedAt: conversation.CreatedAt.Unix(),
@@ -260,6 +260,21 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model
return payload
}
func conversationMuted(ctx context.Context, db *gorm.DB, conversation *model.Conversation) bool {
if db == nil || conversation.ContactID == 0 {
return conversation.Muted
}
var contact model.Contact
if err := db.WithContext(ctx).
Select("id", "blocked").
Where("id = ? AND account_id = ?", conversation.ContactID, conversation.AccountID).
First(&contact).Error; err == nil {
return contact.Blocked
}
return conversation.Muted
}
func serializeAppliedSlaForConversation(ctx context.Context, db *gorm.DB, conversationID uint) map[string]any {
if db == nil || conversationID == 0 {
return nil
+29 -19
View File
@@ -647,17 +647,24 @@ func (s *ConversationService) Mute(ctx context.Context, accountID, id uint) (*mo
return nil, err
}
// Mark conversation as muted via labels or a dedicated field
// For now we use a "muted" label convention
if conversation.Labels != "" && !strings.Contains(conversation.Labels, "muted") {
conversation.Labels += ",muted"
} else if conversation.Labels == "" {
conversation.Labels = "muted"
}
db := s.repo.DB().WithContext(ctx)
if err := db.Transaction(func(tx *gorm.DB) error {
if conversation.ContactID != 0 {
if err := tx.Model(&model.Contact{}).
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
Update("blocked", true).Error; err != nil {
return err
}
}
if err := s.repo.UpdateLabels(ctx, conversation.ID, conversation.Labels); err != nil {
return tx.Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversation.ID, accountID).
Updates(map[string]any{"status": string(model.ConversationStatusResolved), "muted": true}).Error
}); err != nil {
return nil, err
}
conversation.Status = string(model.ConversationStatusResolved)
conversation.Muted = true
// Dispatch EventConversationMuted
s.dispatchConversationEvent(ctx, channel.EventConversationMuted, conversation)
@@ -673,20 +680,23 @@ func (s *ConversationService) Unmute(ctx context.Context, accountID, id uint) (*
return nil, err
}
// Remove muted label
if strings.Contains(conversation.Labels, "muted") {
labels := strings.Split(conversation.Labels, ",")
filtered := make([]string, 0, len(labels))
for _, l := range labels {
if l != "muted" {
filtered = append(filtered, l)
db := s.repo.DB().WithContext(ctx)
if err := db.Transaction(func(tx *gorm.DB) error {
if conversation.ContactID != 0 {
if err := tx.Model(&model.Contact{}).
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
Update("blocked", false).Error; err != nil {
return err
}
}
conversation.Labels = strings.Join(filtered, ",")
if err := s.repo.UpdateLabels(ctx, conversation.ID, conversation.Labels); err != nil {
return nil, err
}
return tx.Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversation.ID, accountID).
Update("muted", false).Error
}); err != nil {
return nil, err
}
conversation.Muted = false
// Dispatch EventConversationUnmuted
s.dispatchConversationEvent(ctx, channel.EventConversationUnmuted, conversation)
@@ -403,6 +403,57 @@ func TestConversationService_Filter_QueryUsesStoredMessageTypeStrings(t *testing
assert.Equal(t, int64(1), result.Count.AllCount)
}
func TestConversationService_Mute_BlocksContactAndResolvesConversation(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(conversation).Update("labels", "vip").Error)
muted, err := svc.Mute(context.Background(), account.ID, conversation.ID)
require.NoError(t, err)
assert.Equal(t, string(model.ConversationStatusResolved), muted.Status)
assert.True(t, muted.Muted)
assert.Equal(t, "vip", muted.Labels)
var persistedConversation model.Conversation
require.NoError(t, db.First(&persistedConversation, conversation.ID).Error)
assert.Equal(t, string(model.ConversationStatusResolved), persistedConversation.Status)
assert.True(t, persistedConversation.Muted)
assert.Equal(t, "vip", persistedConversation.Labels)
var persistedContact model.Contact
require.NoError(t, db.First(&persistedContact, contact.ID).Error)
assert.True(t, persistedContact.Blocked)
}
func TestConversationService_Unmute_UnblocksContactWithoutReopeningConversation(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusResolved))
require.NoError(t, db.Model(contact).Update("blocked", true).Error)
require.NoError(t, db.Model(conversation).Update("muted", true).Error)
unmuted, err := svc.Unmute(context.Background(), account.ID, conversation.ID)
require.NoError(t, err)
assert.Equal(t, string(model.ConversationStatusResolved), unmuted.Status)
assert.False(t, unmuted.Muted)
var persistedConversation model.Conversation
require.NoError(t, db.First(&persistedConversation, conversation.ID).Error)
assert.Equal(t, string(model.ConversationStatusResolved), persistedConversation.Status)
assert.False(t, persistedConversation.Muted)
var persistedContact model.Contact
require.NoError(t, db.First(&persistedContact, contact.ID).Error)
assert.False(t, persistedContact.Blocked)
}
// ========== MarkUnread Tests ==========
func TestConversationService_MarkUnread(t *testing.T) {