diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 524b0f4d..922a33e6 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.94 conversation toggle-priority empty-body parity, prepared as `feat(conversations): align toggle priority fallback`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align toggle priority fallback`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation toggle-priority empty-body parity. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.95 conversation mute contact-block parity, prepared as `feat(conversations): align mute semantics`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align mute semantics`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation mute/unmute and automation `mute_conversation` contact-block parity. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: account conversation `toggle_priority` now follows Chatwoot's permissive member action contract for omitted params: empty bodies no longer fail JSON binding, the endpoint returns empty `200 OK`, and the local no-priority sentinel is persisted through the existing priority service. This retains P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: account conversation `mute`, `unmute`, serialized `muted`, and automation `mute_conversation` now follow Chatwoot's `ConversationMuteHelpers`: mute resolves the conversation, blocks the conversation contact, and exposes `muted` from the contact blocked state; unmute unblocks the contact without reopening the resolved conversation. This retains P3.94 conversation toggle-priority empty-body parity, P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. @@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.95 conversation mute contact-block parity | Implemented for reused conversation mute/unmute actions and automation `mute_conversation`: `POST /conversations/:conversation_id/mute` now resolves the conversation, blocks the contact, and serializes `muted` from the contact blocked state; `unmute` unblocks the contact without reopening the conversation, matching Chatwoot `ConversationMuteHelpers`. | Keep in Review; reopen from B12 conversation action smoke, automation mute smoke, or fresh reference evidence for activity-message wording/Current.user side effects beyond the inspected mute helper. | Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.94 conversation toggle-priority empty-body parity | Implemented for reused conversation priority action and direct API compatibility: `POST /conversations/:conversation_id/toggle_priority` now accepts empty bodies without JSON-binding failure and returns empty `200 OK`, matching Chatwoot `head :ok` behavior while using GoChat's existing no-priority sentinel. | Keep in Review; reopen from B12 conversation action smoke or fresh reference evidence for exact nil-vs-sentinel priority serialization drift. | Focused ConversationHandler toggle-priority tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.93 conversation toggle-status optional status parity | Implemented for reused conversation action paths and direct API compatibility: `POST /conversations/:conversation_id/toggle_status` now accepts empty bodies or `{}` without requiring `status`, then applies Chatwoot `Conversation#toggle_status` fallback semantics. | Keep in Review; reopen from B12 conversation action smoke or fresh reference evidence for bot handoff, assignment side effects, or exact `@status` success value drift beyond the inspected controller/model/Jbuilder contract. | Focused ConversationHandler toggle-status tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.92 automation-rule attachment parity | Implemented for reused automation settings flows: automation rule create/update validates `send_attachment` uploads, normalizes signed upload UUIDs or numeric IDs into stored blob IDs, serializes Chatwoot-style `files`, preserves existing actions when update omits `actions`, and rule execution creates outgoing attachment messages. | Keep in Review; reopen from automation attachment smoke, fresh ActiveStorage signed-id evidence, or reference changes in automation `AttachmentConcern` semantics. | Focused AutomationRuleHandler and AutomationRuleService tests passed; focused ActionService coverage is exercised through automation execution; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -258,6 +259,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | +| P3.95 conversation mute contact-block parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, `internal/handler/api/v1/conversation_serializer.go`, `internal/automation/action_service.go`, conversation handler/service/action tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#mute`, `#unmute`, `reference/chatwoot/app/models/concerns/conversation_mute_helpers.rb`, `reference/chatwoot/app/services/action_service.rb#mute_conversation`, `reference/chatwoot/app/views/api/v1/conversations/partials/_conversation.json.jbuilder`, reused conversation mute/unmute store/actions | Account conversation mute/unmute and automation `mute_conversation` now use Chatwoot's contact-block semantics instead of the old local label/legacy mute-row behavior: mute resolves the conversation, blocks the contact, sets the local conversation muted cache, and serialization derives `muted` from `contact.blocked`; unmute unblocks and clears muted without reopening. | Review by `feat(conversations): align mute semantics`; focused tests prove service persistence, handler empty `200 OK` plus serialized payload state, and automation action side effects; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.94 conversation toggle-priority empty-body parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#toggle_priority`, `reference/chatwoot/app/models/conversation.rb#toggle_priority`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, reused conversation priority action store/components | Account conversation `toggle_priority` now accepts omitted priority/empty request bodies like Chatwoot's `params[:priority]` member action and returns empty `200 OK` instead of a local validation envelope. Explicit priority updates remain handled by the existing priority service. | Review by `feat(conversations): align toggle priority fallback`; focused tests prove empty-body `toggle_priority` succeeds and invalid JSON still fails; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.93 conversation toggle-status optional status parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#toggle_status`, `reference/chatwoot/app/models/conversation.rb#toggle_status`, `reference/chatwoot/app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, reused conversation status action components/store | Account conversation `toggle_status` now accepts omitted status/empty request bodies like Chatwoot and falls back to model toggling: open becomes resolved, while pending/snoozed/resolved becomes open. Explicit `status` behavior and the existing Chatwoot response envelope remain intact. | Review by `feat(conversations): align toggle status fallback`; focused tests prove omitted-status empty-body toggle and `{}` pending-to-open fallback; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | P3.92 automation-rule attachment parity | `internal/handler/api/v1/automation_rule_handler.go`, `internal/automation/service.go`, `internal/automation/model.go`, `internal/automation/action_service.go`, automation rule handler/service/action tests | `reference/chatwoot/app/controllers/api/v1/accounts/automation_rules_controller.rb`, `reference/chatwoot/app/controllers/concerns/attachment_concern.rb`, `reference/chatwoot/app/models/automation_rule.rb`, `reference/chatwoot/app/views/api/v1/accounts/automation_rules/partials/_automation_rule.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/store/modules/automations.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue` | Automation rules now share the Chatwoot attachment contract already implemented for uploads/macros: create/update validate `send_attachment` blobs, normalize signed upload UUIDs or numeric blob IDs to stored upload IDs in `action_params`, serialize rule `files` with `id`, `automation_rule_id`, `file_type`, `account_id`, `file_url`, `blob_id`, and `filename`, preserve existing actions when update omits `actions`, and execution creates outgoing attachment messages with persisted attachment rows. | Review by `feat(automation): align attachment uploads`; focused tests prove create normalization/files serialization, invalid blob `422`, omitted-action update preservation, and automation execution attachment side effects; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -376,6 +378,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(conversations): align mute semantics` | Advances P3.95 with Chatwoot conversation mute/unmute and automation mute parity. GoChat now follows `ConversationMuteHelpers`: mute resolves the conversation, blocks the conversation contact, keeps serialized `muted` tied to contact blocked state, and automation `mute_conversation` applies the same side effects instead of writing only a legacy mute row. | Focused ConversationService mute/unmute tests, ConversationHandler CRUD mute/unmute tests, and ActionService `mute_conversation` tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.95 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align toggle priority fallback` | Advances P3.94 with Chatwoot conversation `toggle_priority` empty-body parity. The account conversation member action now accepts omitted priority/empty bodies, keeps empty `200 OK`, and avoids the local JSON-binding failure that drifted from Chatwoot `params[:priority]`. | Focused ConversationHandler toggle-priority tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.94 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align toggle status fallback` | Advances P3.93 with Chatwoot conversation `toggle_status` optional-status parity. The account conversation endpoint now accepts omitted status/empty bodies and uses Chatwoot model fallback toggling while preserving the existing response envelope consumed by the reused dashboard. | Focused ConversationHandler toggle-status tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.93 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(automation): align attachment uploads` | Advances P3.92 with Chatwoot automation-rule attachment parity. Automation rule create/update now validates and normalizes `send_attachment` uploads like `AttachmentConcern`, exposes Chatwoot-style `files`, preserves existing actions when update omits `actions`, and execution creates outgoing attachment messages with persisted attachment rows. | Focused AutomationRuleHandler and AutomationRuleService tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.92 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2758,3 +2761,4 @@ Verification milestone gates: - 2026-06-07: P3.92 automation-rule attachment checkpoint prepared as `feat(automation): align attachment uploads`; audited Chatwoot `AutomationRulesController`, `AttachmentConcern`, `AutomationRule#file_base_data`, automation rule Jbuilder partial, and reused dashboard automation upload/form flows. GoChat automation rule create/update now validates `send_attachment` direct uploads, normalizes upload UUIDs or numeric blob IDs into stored `blob_id` values, serializes `files` with `automation_rule_id`, rejects invalid blobs with raw `422`, preserves existing actions when update omits `actions`, and automation execution creates outgoing attachment messages with persisted attachment rows. Focused AutomationRuleHandler/AutomationRuleService tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.93 conversation toggle-status checkpoint prepared as `feat(conversations): align toggle status fallback`; audited Chatwoot `ConversationsController#toggle_status`, `Conversation#toggle_status`, `toggle_status.json.jbuilder`, and reused dashboard conversation status API/store/actions. GoChat account conversation `toggle_status` now accepts omitted `status` and empty request bodies, toggles open conversations to resolved and pending/snoozed/resolved conversations to open, and keeps the Chatwoot response envelope with `success`, display `conversation_id`, `current_status`, and `snoozed_until`. Focused ConversationHandler toggle-status tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.94 conversation toggle-priority checkpoint prepared as `feat(conversations): align toggle priority fallback`; audited Chatwoot `ConversationsController#toggle_priority`, `Conversation#toggle_priority`, and reused dashboard conversation priority API/store actions. GoChat account conversation `toggle_priority` now accepts omitted `priority` and empty request bodies, returns empty `200 OK` instead of a local JSON-binding error, and continues to persist the existing no-priority sentinel through the priority service. Focused ConversationHandler toggle-priority tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.95 conversation mute checkpoint prepared as `feat(conversations): align mute semantics`; audited Chatwoot `ConversationsController#mute/#unmute`, `ConversationMuteHelpers`, `ActionService#mute_conversation`, `_conversation.json.jbuilder`, and reused dashboard mute/unmute store actions. GoChat account conversation mute now resolves the conversation, blocks the contact, and serializes `muted` from contact blocked state; unmute unblocks without reopening; automation `mute_conversation` applies the same Chatwoot side effects instead of writing only a legacy mute row. Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/automation/action_service.go b/internal/automation/action_service.go index af195076..5a35634b 100644 --- a/internal/automation/action_service.go +++ b/internal/automation/action_service.go @@ -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. diff --git a/internal/automation/action_service_test.go b/internal/automation/action_service_test.go index 252b522b..0b21bf7a 100644 --- a/internal/automation/action_service_test.go +++ b/internal/automation/action_service_test.go @@ -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() diff --git a/internal/handler/api/v1/conversation_handler_crud_test.go b/internal/handler/api/v1/conversation_handler_crud_test.go index 44613e23..2c78d592 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -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() { diff --git a/internal/handler/api/v1/conversation_serializer.go b/internal/handler/api/v1/conversation_serializer.go index 7797560d..51f0c5d7 100644 --- a/internal/handler/api/v1/conversation_serializer.go +++ b/internal/handler/api/v1/conversation_serializer.go @@ -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 diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 6c2ce80e..08f9a352 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -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) diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index 2e064905..a7afc1fd 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -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) {