feat(conversations): align mute semantics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user