diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index e30c2734..05ae8a39 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 P5.3e conversation maintenance search indexing, prepared as `feat(search): index conversation maintenance`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): index conversation maintenance`. -- Latest documentation/tooling checkpoint: this tracker update records Meilisearch reindex fan-out after scheduled snooze reopen, auto-resolution, and message-status maintenance mutations. +- Current tracking checkpoint: 2026-06-07 P5.3f automation action search indexing, prepared as `feat(search): index automation actions`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): index automation actions`. +- Latest documentation/tooling checkpoint: this tracker update records Meilisearch reindex fan-out after automation, macro, and AgentBot action mutations. - 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: conversation maintenance jobs now enqueue durable search reindex follow-ups after scheduled snooze reopen, auto-resolution, and outgoing message status updates, so Meilisearch conversation/message documents stay fresh after background state transitions. This retains P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action 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: automation, macro, and AgentBot actions now synchronize Meilisearch after creating searchable messages or mutating conversation/contact fields, including durable macro execution jobs and event-triggered automation listeners. This retains P5.3e conversation maintenance indexing, P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action 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 `972` registered routes after enterprise account 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 | P5.3f automation action search indexing | Implemented for Meilisearch automation/macro parity: `send_message`, `send_attachment`, and `add_private_note` index created messages plus parent conversations; status, priority, assignee, team, label, SLA, and mute actions reindex the updated conversation; mute also reindexes the blocked contact; macro worker replay and event-triggered automation/AgentBot listeners receive the durable search indexer during bootstrap. | Keep in Review; reopen from live Meilisearch gate, automation/macro smoke, or a fresh action path that mutates searchable conversation/message/contact fields without indexing. | Focused automation action/search propagation tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P5.3e conversation maintenance search indexing | Implemented for Meilisearch maintenance-job parity: scheduled snooze reopen and account auto-resolution reindex updated conversations, message-status maintenance reindexes changed messages plus the parent conversation, and bootstrap registers search-aware maintenance handlers after the durable search indexer is wired. | Keep in Review; reopen from live Meilisearch gate, scheduled maintenance smoke, or a fresh background mutation path that changes searchable conversation/message fields without indexing. | Focused conversation maintenance search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P5.3d conversation bulk-action search indexing | Implemented for Meilisearch conversation bulk-action parity: `conversation:bulk_action` now reindexes updated account conversations after status, assignee, team, snooze, or label mutations, and the app registers a search-aware bulk-action runner after the durable search indexer is wired. | Keep in Review; reopen from live Meilisearch gate, conversation bulk-action search smoke, or a fresh mutation path that changes searchable conversation fields without indexing. | Focused conversation bulk-action search test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P5.3c contact label search indexing | Implemented for Meilisearch contact-label parity: contact search documents now include `labels`, durable `search:index` contact replay loads current `contact_labels`/`tags`, direct contact label replacement reindexes the contact, and contact bulk-action label/delete workers enqueue search index/delete follow-ups after account-scoped side effects. | Keep in Review; reopen from live Meilisearch gate, contact label search smoke, or a fresh mutation path that changes contact labels without indexing. | Focused service/search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -2804,3 +2805,4 @@ Verification milestone gates: - 2026-06-07: P5.3c contact label search-index checkpoint prepared as `feat(search): index contact labels`; audited Meilisearch contact document/filter behavior, contact label endpoints, and the new contact bulk-action mutation path. GoChat contact search documents now include `labels`, durable contact index replay preloads current contact labels from `contact_labels`/`tags`, direct contact label replacement reindexes the contact, and contact bulk-action label/delete jobs enqueue search index/delete follow-ups through the durable search indexer. Focused service/search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P5.3d conversation bulk-action search-index checkpoint prepared as `feat(search): index conversation bulk actions`; audited the Chatwoot account `BulkActionsJob` mutation path and the P5.3 Meilisearch durability contract. GoChat conversation bulk-action worker replay now reindexes every updated account-scoped conversation after bulk status, assignee, team, snooze, or label updates, and bootstrap registers the search-aware bulk-action runner after the durable search indexer is wired. Focused conversation bulk-action search test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P5.3e conversation maintenance search-index checkpoint prepared as `feat(search): index conversation maintenance`; audited Chatwoot scheduled conversation maintenance jobs and the P5.3 Meilisearch durability contract. GoChat scheduled snooze reopen and account auto-resolution jobs now reindex updated conversations, deferred outgoing message-status jobs reindex updated messages plus their parent conversation, and bootstrap registers the search-aware maintenance runner after the durable search indexer is wired. Focused conversation maintenance search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P5.3f automation action search-index checkpoint prepared as `feat(search): index automation actions`; audited GoChat automation, macro, AgentBot, and durable macro execution action paths against the P5.3 Meilisearch durability contract. GoChat shared action execution now reindexes created outgoing/private/attachment messages plus parent conversations, reindexes conversations after status/priority/assignment/team/label/SLA/mute mutations, reindexes contacts after mute blocks them, keeps conversation label strings current for action label changes, and wires durable search indexing into automation listeners, macro services, AgentBot rules, and macro worker replay. Focused automation action/search propagation tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 38821039..dad67571 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -331,10 +331,9 @@ func Bootstrap(env string) (*App, error) { // Create channel dispatcher for event-driven architecture (ref: Chatwoot Dispatcher) channelDispatcher := channel.NewDispatcher(workerPool) - // P9: AgentBot rule engine services (declared early so listener can be registered on dispatcher) + // P9: AgentBot rule engine services botRuleService := automation.NewBotRuleService(&dbProvider{db: db}) botTriggerConfigService := automation.NewBotTriggerConfigService(&dbProvider{db: db}) - agentBotRuleListener := automation.NewAgentBotRuleListener(&dbProvider{db: db}) // Step 8b: Wire channel providers (Facebook, Instagram, Telegram, Twitter, Microsoft, Google) // Reference: Chatwoot registers channel providers in config/initializers/channels.rb @@ -395,13 +394,6 @@ func Bootstrap(env string) (*App, error) { // Register listeners on the channel dispatcher (keyed by Name(), not channel type) channelDispatcher.Register(fbListener) channelDispatcher.Register(igListener) - // P9: Register AgentBot rule listener on the dispatcher - channelDispatcher.Register(agentBotRuleListener) - // M6: Register automation rule listener on the dispatcher for event-triggered automation - automation.RegisterAutomationRuleListenerWithWorker(channelDispatcher, &dbProvider{db: db}, workerPool) - // M6: Register CSAT survey listener on the dispatcher for conversation.resolved events - channelDispatcher.Register(automation.NewCsatSurveyListenerWithWorker(&dbProvider{db: db}, workerPool)) - // Create Facebook webhook handler (Gin HTTP handler for FB/IG webhook endpoints) facebookWebhookHandler := webhook.NewFacebookWebhookHandler(fbProvider, igProvider, db, channelDispatcher) facebookWebhookHandler.WithWorkerPool(workerPool) @@ -687,6 +679,15 @@ func Bootstrap(env string) (*App, error) { companyService.SetSearchIndexer(searchIndexer) companyService.SetSearchReader(searchService) articleService.SetSearchIndexer(searchIndexer) + automationRuleService.SetSearchIndexer(searchIndexer) + macroService.SetSearchIndexer(searchIndexer) + botRuleService.SetSearchIndexer(searchIndexer) + // Event-triggered automation/AgentBot listeners are registered after the durable + // search indexer exists so action side effects keep Meilisearch current. + channelDispatcher.Register(automation.NewAgentBotRuleListenerWithSearchIndexer(&dbProvider{db: db}, searchIndexer)) + automation.RegisterAutomationRuleListenerWithWorkerAndSearchIndexer(channelDispatcher, &dbProvider{db: db}, workerPool, searchIndexer) + // M6: Register CSAT survey listener on the dispatcher for conversation.resolved events + channelDispatcher.Register(automation.NewCsatSurveyListenerWithWorker(&dbProvider{db: db}, workerPool)) // Custom attribute definition + custom filter + custom attribute value services customAttributeDefinitionService := service.NewCustomAttributeDefinitionService(customAttributeDefinitionRepo) diff --git a/internal/automation/action_service.go b/internal/automation/action_service.go index fa5ad695..8a18f695 100644 --- a/internal/automation/action_service.go +++ b/internal/automation/action_service.go @@ -33,6 +33,15 @@ type ActionService struct { webhookDeliverer AutomationWebhookDeliverer transcriptDeliverer AutomationTranscriptDeliverer worker *worker.WorkerPool + searchIndexer ActionSearchIndexer +} + +// ActionSearchIndexer is the narrow search synchronization boundary needed by +// automation and macro actions. service.DurableSearchIndexer satisfies it. +type ActionSearchIndexer interface { + IndexConversation(ctx context.Context, conversation *model.Conversation) error + IndexMessage(ctx context.Context, message *model.Message) error + IndexContact(ctx context.Context, contact *model.Contact) error } var defaultWebhookDelivererFactory = func() AutomationWebhookDeliverer { @@ -63,6 +72,10 @@ func (s *ActionService) SetWorkerPool(wp *worker.WorkerPool) { RegisterActionDeliveryJobs(wp, s.db) } +func (s *ActionService) SetSearchIndexer(indexer ActionSearchIndexer) { + s.searchIndexer = indexer +} + func setAutomationActionDeliverersForTest(webhook AutomationWebhookDeliverer, transcript AutomationTranscriptDeliverer) func() { originalWebhookFactory := defaultWebhookDelivererFactory originalTranscriptFactory := defaultTranscriptDelivererFactory @@ -99,60 +112,93 @@ func (s *ActionService) ExecuteWithResult(ctx context.Context, accountID uint, c result.ActionName = resolvedAction.ActionName var deliveryResult ActionDeliveryResult + var messageIDs []uint + indexConversation := false + indexContact := false switch resolvedAction.ActionName { case "send_message": - err = s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID) + var msg *model.Message + msg, err = s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID) + if msg != nil { + messageIDs = append(messageIDs, msg.ID) + indexConversation = true + } case "send_email_to_team": deliveryResult, err = s.handleSendEmailToTeam(ctx, accountID, conversationID, resolvedAction) case "add_label": err = s.handleAddLabel(ctx, accountID, conversationID, resolvedAction) + indexConversation = true case "remove_label": err = s.handleRemoveLabel(ctx, accountID, conversationID, resolvedAction) + indexConversation = true case "assign_agent": err = s.handleAssignAgent(ctx, accountID, conversationID, resolvedAction) + indexConversation = true case "assign_team": err = s.handleAssignTeam(ctx, accountID, conversationID, resolvedAction) + indexConversation = true case "remove_assigned_agent": err = s.handleRemoveAssignedAgent(ctx, accountID, conversationID) + indexConversation = true case "remove_assigned_team": err = s.handleRemoveAssignedTeam(ctx, accountID, conversationID) + indexConversation = true case "send_webhook_event": deliveryResult, err = s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction) case "mute_conversation": err = s.handleMuteConversation(ctx, accountID, conversationID) + indexConversation = true + indexContact = true case "change_status": err = s.handleChangeStatus(ctx, accountID, conversationID, resolvedAction) + indexConversation = true case "resolve_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}, }) + indexConversation = true case "open_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "open"}, }) + indexConversation = true case "pending_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "pending"}, }) + indexConversation = true case "snooze_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "snoozed"}, }) + indexConversation = true case "change_priority": err = s.handleChangePriority(ctx, accountID, conversationID, resolvedAction) + indexConversation = true case "add_sla": err = s.handleAddSla(ctx, accountID, conversationID, resolvedAction) + indexConversation = true case "send_email_transcript": deliveryResult, err = s.handleSendEmailTranscript(ctx, accountID, conversationID, resolvedAction) case "send_attachment": - err = s.handleSendAttachment(ctx, accountID, conversationID, resolvedAction) + var msg *model.Message + msg, err = s.handleSendAttachment(ctx, accountID, conversationID, resolvedAction) + if msg != nil { + messageIDs = append(messageIDs, msg.ID) + indexConversation = true + } case "add_private_note": - err = s.handleAddPrivateNote(ctx, accountID, conversationID, resolvedAction, source, sourceID) + var msg *model.Message + msg, err = s.handleAddPrivateNote(ctx, accountID, conversationID, resolvedAction, source, sourceID) + if msg != nil { + messageIDs = append(messageIDs, msg.ID) + indexConversation = true + } default: err = fmt.Errorf("unsupported action: %s", resolvedAction.ActionName) } @@ -163,6 +209,7 @@ func (s *ActionService) ExecuteWithResult(ctx context.Context, accountID uint, c result.Error = err.Error() return result, err } + s.indexActionSearchDocuments(ctx, accountID, conversationID, messageIDs, indexConversation, indexContact) result.Status = ExecutionStatusSuccess return result, nil } @@ -189,10 +236,10 @@ func (s *ActionService) resolveTemplateVars(ctx context.Context, conversationID // handleSendMessage sends a message to the conversation. // Reference: Chatwoot send_message action — creates an outgoing message in the conversation -func (s *ActionService) handleSendMessage(ctx context.Context, accountID, conversationID uint, action Action, source ActionSource, sourceID uint) error { +func (s *ActionService) handleSendMessage(ctx context.Context, accountID, conversationID uint, action Action, source ActionSource, sourceID uint) (*model.Message, error) { content, _ := action.ActionParams["content"].(string) if content == "" { - return fmt.Errorf("send_message action requires 'content' param") + return nil, fmt.Errorf("send_message action requires 'content' param") } // Determine sender: for automation, use the bot/user who owns the rule @@ -213,7 +260,10 @@ func (s *ActionService) handleSendMessage(ctx context.Context, accountID, conver Private: false, } - return s.db.DB().WithContext(ctx).Create(msg).Error + if err := s.db.DB().WithContext(ctx).Create(msg).Error; err != nil { + return nil, err + } + return msg, nil } // handleSendEmailToTeam sends Chatwoot-style automation team notifications. @@ -283,6 +333,9 @@ func (s *ActionService) handleAddLabel(ctx context.Context, accountID, conversat // Continue with remaining labels } } + if err := s.mergeConversationLabels(ctx, accountID, conversationID, labels, nil); err != nil { + return err + } return nil } @@ -305,6 +358,9 @@ func (s *ActionService) handleRemoveLabel(ctx context.Context, accountID, conver applogger.L().Errorf("failed to remove label '%s' from conversation %d: %v", label, conversationID, err) } } + if err := s.mergeConversationLabels(ctx, accountID, conversationID, nil, labels); err != nil { + return err + } return nil } @@ -588,26 +644,26 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID // handleSendAttachment sends an attachment message to the conversation. // Reference: Chatwoot send_attachment action -func (s *ActionService) handleSendAttachment(ctx context.Context, accountID, conversationID uint, action Action) error { +func (s *ActionService) handleSendAttachment(ctx context.Context, accountID, conversationID uint, action Action) (*model.Message, error) { attachmentURL, _ := action.ActionParams["attachment_url"].(string) if attachmentURL != "" { return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), model.DirectUpload{FileURL: attachmentURL, OriginalName: attachmentURL, FileType: "file"}) } blobID := extractUintParam(action.ActionParams, "blob_id") if blobID == 0 { - return fmt.Errorf("send_attachment action requires 'blob_id' param") + return nil, fmt.Errorf("send_attachment action requires 'blob_id' param") } var upload model.DirectUpload if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, blobID).First(&upload).Error; err != nil { - return err + return nil, err } return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), upload) } -func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, conversationID, senderID uint, upload model.DirectUpload) error { +func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, conversationID, senderID uint, upload model.DirectUpload) (*model.Message, error) { var conversation model.Conversation if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, conversationID).First(&conversation).Error; err != nil { - return err + return nil, err } senderType := "agent" if senderID == 0 { @@ -625,7 +681,7 @@ func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, Private: false, Status: "sent", } - return s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(msg).Error; err != nil { return err } @@ -642,15 +698,18 @@ func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, attachment.FileType = "file" } return tx.Create(attachment).Error - }) + }); err != nil { + return nil, err + } + return msg, nil } // handleAddPrivateNote adds a private note (internal comment) to the conversation. // Reference: Chatwoot add_private_note action -func (s *ActionService) handleAddPrivateNote(ctx context.Context, accountID, conversationID uint, action Action, source ActionSource, sourceID uint) error { +func (s *ActionService) handleAddPrivateNote(ctx context.Context, accountID, conversationID uint, action Action, source ActionSource, sourceID uint) (*model.Message, error) { content, _ := action.ActionParams["content"].(string) if content == "" { - return fmt.Errorf("add_private_note action requires 'content' param") + return nil, fmt.Errorf("add_private_note action requires 'content' param") } msg := &model.Message{ @@ -664,7 +723,61 @@ func (s *ActionService) handleAddPrivateNote(ctx context.Context, accountID, con Private: true, } - return s.db.DB().WithContext(ctx).Create(msg).Error + if err := s.db.DB().WithContext(ctx).Create(msg).Error; err != nil { + return nil, err + } + return msg, nil +} + +func (s *ActionService) indexActionSearchDocuments(ctx context.Context, accountID, conversationID uint, messageIDs []uint, indexConversation bool, indexContact bool) { + if s.searchIndexer == nil { + return + } + for _, messageID := range messageIDs { + if messageID == 0 { + continue + } + var message model.Message + if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", messageID, accountID).First(&message).Error; err != nil { + applogger.L().Warnf("search index sync failed for automation message %d: %v", messageID, err) + continue + } + if err := s.searchIndexer.IndexMessage(ctx, &message); err != nil { + applogger.L().Warnf("search index sync failed for automation message %d: %v", messageID, err) + } + } + if !indexConversation && !indexContact { + return + } + var conversation model.Conversation + if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil { + applogger.L().Warnf("search index sync failed for automation conversation %d: %v", conversationID, err) + return + } + if indexConversation { + if err := s.searchIndexer.IndexConversation(ctx, &conversation); err != nil { + applogger.L().Warnf("search index sync failed for automation conversation %d: %v", conversation.ID, err) + } + } + if indexContact && conversation.ContactID != 0 { + var contact model.Contact + if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", conversation.ContactID, accountID).First(&contact).Error; err != nil { + applogger.L().Warnf("search index sync failed for automation contact %d: %v", conversation.ContactID, err) + return + } + if err := s.searchIndexer.IndexContact(ctx, &contact); err != nil { + applogger.L().Warnf("search index sync failed for automation contact %d: %v", contact.ID, err) + } + } +} + +func (s *ActionService) mergeConversationLabels(ctx context.Context, accountID, conversationID uint, add []string, remove []string) error { + var conversation model.Conversation + if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil { + return err + } + merged := mergeActionConversationLabels(conversation.Labels, add, remove) + return s.db.DB().WithContext(ctx).Model(&model.Conversation{}).Where("id = ? AND account_id = ?", conversationID, accountID).Update("labels", merged).Error } // =========================== @@ -718,6 +831,41 @@ func extractStringSlice(params map[string]interface{}, key string) []string { } } +func mergeActionConversationLabels(existing string, add []string, remove []string) string { + labels := map[string]bool{} + order := []string{} + for _, label := range strings.Split(existing, ",") { + label = strings.TrimSpace(label) + if label == "" || labels[label] { + continue + } + labels[label] = true + order = append(order, label) + } + for _, label := range add { + label = strings.TrimSpace(label) + if label == "" || labels[label] { + continue + } + labels[label] = true + order = append(order, label) + } + removeSet := map[string]bool{} + for _, label := range remove { + label = strings.TrimSpace(label) + if label != "" { + removeSet[label] = true + } + } + merged := make([]string, 0, len(order)) + for _, label := range order { + if !removeSet[label] { + merged = append(merged, label) + } + } + return strings.Join(merged, ",") +} + // extractUintParam extracts a uint parameter from action params. // Handles both numeric and string representations. func extractUintParam(params map[string]interface{}, key string) uint { diff --git a/internal/automation/action_service_test.go b/internal/automation/action_service_test.go index 4c1350ca..fd283b60 100644 --- a/internal/automation/action_service_test.go +++ b/internal/automation/action_service_test.go @@ -449,6 +449,126 @@ func TestActionService_MuteConversation_BlocksContactAndResolvesConversation(t * } } +func TestActionService_SearchIndexesMessageAndConversationActions(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) + indexer := &recordingActionSearchIndexer{} + svc := NewActionService(dbProvider) + svc.SetSearchIndexer(indexer) + + _, err := svc.ExecuteWithResult(context.Background(), accountID, conversationID, Action{ + ActionName: "send_message", + ActionParams: map[string]interface{}{"content": "indexed automation reply"}, + }, ActionSourceAutomation, userID) + if err != nil { + t.Fatalf("send message action: %v", err) + } + + if len(indexer.messages) != 1 || indexer.messages[0].Content != "indexed automation reply" { + t.Fatalf("expected created message to be indexed, got %#v", indexer.messages) + } + if len(indexer.conversations) != 1 || indexer.conversations[0].ID != conversationID { + t.Fatalf("expected parent conversation to be indexed, got %#v", indexer.conversations) + } +} + +func TestActionService_SearchIndexesConversationAndContactMutations(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) + indexer := &recordingActionSearchIndexer{} + svc := NewActionService(dbProvider) + svc.SetSearchIndexer(indexer) + + _, err := svc.ExecuteWithResult(context.Background(), accountID, conversationID, Action{ + ActionName: "mute_conversation", + }, ActionSourceAutomation, userID) + if err != nil { + t.Fatalf("mute conversation action: %v", err) + } + + if len(indexer.conversations) != 1 || indexer.conversations[0].Status != string(model.ConversationStatusResolved) || !indexer.conversations[0].Muted { + t.Fatalf("expected resolved muted conversation to be indexed, got %#v", indexer.conversations) + } + if len(indexer.contacts) != 1 || indexer.contacts[0].ID != contactID || !indexer.contacts[0].Blocked { + t.Fatalf("expected blocked contact to be indexed, got %#v", indexer.contacts) + } +} + +func TestAutomationRuleService_MatchAndExecute_PropagatesSearchIndexer(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + inboxID := seedTestInbox(db, t, accountID) + contactID := seedTestContact(db, t, accountID) + conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) + indexer := &recordingActionSearchIndexer{} + svc := NewAutomationRuleService(dbProvider) + svc.SetSearchIndexer(indexer) + rule := &AutomationRule{ + AccountID: accountID, + EventName: "conversation_created", + Name: "status index", + Conditions: Conditions{}, + Actions: Actions{{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}}}, + Active: true, + } + if err := svc.Create(context.Background(), rule); err != nil { + t.Fatalf("create automation rule: %v", err) + } + + if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil { + t.Fatalf("match and execute: %v", err) + } + + if len(indexer.conversations) != 1 || indexer.conversations[0].Status != string(model.ConversationStatusResolved) { + t.Fatalf("expected automation rule action to index updated conversation, got %#v", indexer.conversations) + } +} + +func TestMacroService_WorkerPropagatesSearchIndexer(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) + displayID := uint(42) + if err := db.Model(&model.Conversation{}).Where("id = ?", conversationID).Update("display_id", displayID).Error; err != nil { + t.Fatalf("set display id: %v", err) + } + macro := &Macro{AccountID: accountID, Name: "reply", CreatedByID: userID, UpdatedByID: userID, Actions: Actions{{ActionName: "send_message", ActionParams: map[string]interface{}{"content": "macro indexed"}}}} + if err := NewMacroService(dbProvider).Create(context.Background(), macro); err != nil { + t.Fatalf("create macro: %v", err) + } + wp := worker.NewWorkerPool(db) + indexer := &recordingActionSearchIndexer{} + svc := NewMacroServiceWithWorker(dbProvider, wp) + svc.SetSearchIndexer(indexer) + + if err := svc.ExecuteForDisplayIDs(context.Background(), accountID, macro.ID, []uint{displayID}, userID); err != nil { + t.Fatalf("enqueue macro: %v", err) + } + processed, err := wp.ProcessOne(context.Background()) + if err != nil || !processed { + t.Fatalf("process macro job: processed=%v err=%v", processed, err) + } + + if len(indexer.messages) != 1 || indexer.messages[0].Content != "macro indexed" { + t.Fatalf("expected macro-created message to be indexed, got %#v", indexer.messages) + } + if len(indexer.conversations) != 1 || indexer.conversations[0].ID != conversationID { + t.Fatalf("expected macro conversation to be indexed, got %#v", indexer.conversations) + } +} + func TestAutomationRuleService_MatchAndExecute_RecordsEmailTranscriptFailureMetadata(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() @@ -529,3 +649,24 @@ func (d *recordingTranscriptDeliverer) DeliverTranscript(ctx context.Context, re } return result, d.err } + +type recordingActionSearchIndexer struct { + conversations []model.Conversation + messages []model.Message + contacts []model.Contact +} + +func (r *recordingActionSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error { + r.conversations = append(r.conversations, *conversation) + return nil +} + +func (r *recordingActionSearchIndexer) IndexMessage(ctx context.Context, message *model.Message) error { + r.messages = append(r.messages, *message) + return nil +} + +func (r *recordingActionSearchIndexer) IndexContact(ctx context.Context, contact *model.Contact) error { + r.contacts = append(r.contacts, *contact) + return nil +} diff --git a/internal/automation/agent_bot_rule_listener.go b/internal/automation/agent_bot_rule_listener.go index 42267e6d..ec5bdd82 100644 --- a/internal/automation/agent_bot_rule_listener.go +++ b/internal/automation/agent_bot_rule_listener.go @@ -20,9 +20,9 @@ import ( // - message_created → loads BotRules for event_name "message_created" // - message_updated → loads BotRules for event_name "message_updated" type AgentBotRuleListener struct { - db DBProvider - botRuleService *BotRuleService - triggerSvc *BotTriggerConfigService + db DBProvider + botRuleService *BotRuleService + triggerSvc *BotTriggerConfigService } // NewAgentBotRuleListener creates a new AgentBotRuleListener. @@ -34,6 +34,16 @@ func NewAgentBotRuleListener(db DBProvider) *AgentBotRuleListener { } } +func NewAgentBotRuleListenerWithSearchIndexer(db DBProvider, indexer ActionSearchIndexer) *AgentBotRuleListener { + service := NewBotRuleService(db) + service.SetSearchIndexer(indexer) + return &AgentBotRuleListener{ + db: db, + botRuleService: service, + triggerSvc: NewBotTriggerConfigService(db), + } +} + // Name returns the unique identifier for this listener. func (l *AgentBotRuleListener) Name() string { return "agent_bot_rule_listener" diff --git a/internal/automation/bot_rule_service.go b/internal/automation/bot_rule_service.go index 2a74e44b..118d5ceb 100644 --- a/internal/automation/bot_rule_service.go +++ b/internal/automation/bot_rule_service.go @@ -13,7 +13,8 @@ import ( // BotRuleService reuses ConditionsFilterService.MatchConditions and ActionService.Execute // to avoid duplicating condition matching and action execution logic. type BotRuleService struct { - db DBProvider + db DBProvider + searchIndexer ActionSearchIndexer } // NewBotRuleService creates a new BotRuleService. @@ -21,6 +22,10 @@ func NewBotRuleService(db DBProvider) *BotRuleService { return &BotRuleService{db: db} } +func (s *BotRuleService) SetSearchIndexer(indexer ActionSearchIndexer) { + s.searchIndexer = indexer +} + // GetByID retrieves a bot rule by ID. func (s *BotRuleService) GetByID(ctx context.Context, id uint) (*BotRule, error) { var rule BotRule @@ -149,6 +154,7 @@ func (s *BotRuleService) MatchAndExecute(ctx context.Context, accountID uint, ev } actionSvc := NewActionService(s.db) + actionSvc.SetSearchIndexer(s.searchIndexer) for _, rule := range rules { matched, err := MatchConditions(ctx, s.db.DB(), rule.Conditions, conversation, eventData) diff --git a/internal/automation/listener.go b/internal/automation/listener.go index 586514d5..323c419b 100644 --- a/internal/automation/listener.go +++ b/internal/automation/listener.go @@ -42,6 +42,12 @@ func NewAutomationRuleListenerWithWorker(db DBProvider, wp *worker.WorkerPool) * } } +func NewAutomationRuleListenerWithWorkerAndSearchIndexer(db DBProvider, wp *worker.WorkerPool, indexer ActionSearchIndexer) *AutomationRuleListener { + service := NewAutomationRuleServiceWithWorker(db, wp) + service.SetSearchIndexer(indexer) + return &AutomationRuleListener{db: db, ruleService: service} +} + // Name returns the unique identifier for this listener. func (l *AutomationRuleListener) Name() string { return "automation_rule_listener" @@ -302,6 +308,12 @@ func RegisterAutomationRuleListenerWithWorker(dispatcher *channel.Dispatcher, db applogger.L().Infof("registered automation rule listener with durable worker") } +func RegisterAutomationRuleListenerWithWorkerAndSearchIndexer(dispatcher *channel.Dispatcher, db DBProvider, wp *worker.WorkerPool, indexer ActionSearchIndexer) { + listener := NewAutomationRuleListenerWithWorkerAndSearchIndexer(db, wp, indexer) + dispatcher.Register(listener) + applogger.L().Infof("registered automation rule listener with durable worker and search indexer") +} + // String helper for event name validation func isValidAutomationEventName(name string) bool { validNames := map[string]bool{ diff --git a/internal/automation/macro_service.go b/internal/automation/macro_service.go index cedfc4b7..64e37a16 100644 --- a/internal/automation/macro_service.go +++ b/internal/automation/macro_service.go @@ -17,8 +17,9 @@ import ( // Reference: Chatwoot Macros::ExecutionService — same action handler pattern as AutomationRule, // but user-originated (supports 'self' assign). stamps user info. type MacroService struct { - db DBProvider - worker *worker.WorkerPool + db DBProvider + worker *worker.WorkerPool + searchIndexer ActionSearchIndexer } const TaskTypeMacroExecution = "automation:macro_execution" @@ -40,6 +41,11 @@ func (s *MacroService) SetWorkerPool(wp *worker.WorkerPool) { RegisterMacroExecutionJobs(wp, s.db) } +func (s *MacroService) SetSearchIndexer(indexer ActionSearchIndexer) { + s.searchIndexer = indexer + RegisterMacroExecutionSearchIndexer(s.worker, s.db, indexer) +} + // GetByID retrieves a macro by ID. func (s *MacroService) GetByID(ctx context.Context, id uint) (*Macro, error) { var macro Macro @@ -317,6 +323,7 @@ func (s *MacroService) Execute(ctx context.Context, accountID uint, conversation applogger.L().Infof("executing macro %d (%s) on conversation %d by user %d", macro.ID, macro.Name, conversationID, userID) actionSvc := NewActionServiceWithWorker(s.db, s.worker) + actionSvc.SetSearchIndexer(s.searchIndexer) for _, action := range macro.Actions { // Inject _source_user_id for "self" assignment support @@ -390,6 +397,7 @@ func (s *MacroService) executeForDisplayIDsNow(ctx context.Context, accountID ui } actionSvc := NewActionServiceWithWorker(s.db, s.worker) + actionSvc.SetSearchIndexer(s.searchIndexer) for _, conversation := range conversations { applogger.L().Infof("executing macro %d (%s) on conversation %d by user %d", macro.ID, macro.Name, conversation.ID, userID) for _, action := range macro.Actions { diff --git a/internal/automation/macro_worker.go b/internal/automation/macro_worker.go index f3c18be3..b3608185 100644 --- a/internal/automation/macro_worker.go +++ b/internal/automation/macro_worker.go @@ -22,19 +22,32 @@ var macroExecutionRegistrations sync.Map // RegisterMacroExecutionJobs wires Chatwoot's MacrosExecutionJob equivalent. // The job resolves account-scoped conversation display IDs at perform time. func RegisterMacroExecutionJobs(wp *worker.WorkerPool, db DBProvider) { + registerMacroExecutionJobs(wp, db, nil) +} + +func RegisterMacroExecutionSearchIndexer(wp *worker.WorkerPool, db DBProvider, indexer ActionSearchIndexer) { + registerMacroExecutionJobs(wp, db, indexer) +} + +func registerMacroExecutionJobs(wp *worker.WorkerPool, db DBProvider, indexer ActionSearchIndexer) { if wp == nil || db == nil { return } - if _, loaded := macroExecutionRegistrations.LoadOrStore(wp, struct{}{}); loaded { + runner := ¯oExecutionJobRunner{db: db, worker: wp, searchIndexer: indexer} + actual, loaded := macroExecutionRegistrations.LoadOrStore(wp, runner) + if loaded { + if indexer != nil { + actual.(*macroExecutionJobRunner).searchIndexer = indexer + } return } - runner := ¯oExecutionJobRunner{db: db, worker: wp} wp.Register(TaskTypeMacroExecution, runner.perform) } type macroExecutionJobRunner struct { - db DBProvider - worker *worker.WorkerPool + db DBProvider + worker *worker.WorkerPool + searchIndexer ActionSearchIndexer } func (r *macroExecutionJobRunner) perform(ctx context.Context, job *model.BackgroundJob) error { @@ -42,5 +55,7 @@ func (r *macroExecutionJobRunner) perform(ctx context.Context, job *model.Backgr if err := json.Unmarshal(job.Payload, &payload); err != nil { return fmt.Errorf("unmarshal macro execution job: %w", err) } - return NewMacroServiceWithWorker(r.db, r.worker).executeForDisplayIDsNow(ctx, payload.AccountID, payload.MacroID, payload.ConversationIDs, payload.UserID) + svc := NewMacroServiceWithWorker(r.db, r.worker) + svc.SetSearchIndexer(r.searchIndexer) + return svc.executeForDisplayIDsNow(ctx, payload.AccountID, payload.MacroID, payload.ConversationIDs, payload.UserID) } diff --git a/internal/automation/service.go b/internal/automation/service.go index ec5a33d6..661114eb 100644 --- a/internal/automation/service.go +++ b/internal/automation/service.go @@ -21,8 +21,9 @@ type DBProvider interface { // AutomationRuleService provides CRUD + condition matching + action execution for automation rules. // Reference: Chatwoot AutomationRules::ActionService + ConditionsFilterService pattern type AutomationRuleService struct { - db DBProvider - worker *worker.WorkerPool + db DBProvider + worker *worker.WorkerPool + searchIndexer ActionSearchIndexer } // NewAutomationRuleService creates a new AutomationRuleService. @@ -41,6 +42,10 @@ func (s *AutomationRuleService) SetWorkerPool(wp *worker.WorkerPool) { RegisterActionDeliveryJobs(wp, s.db) } +func (s *AutomationRuleService) SetSearchIndexer(indexer ActionSearchIndexer) { + s.searchIndexer = indexer +} + // GetByID retrieves an automation rule by ID. func (s *AutomationRuleService) GetByID(ctx context.Context, id uint) (*AutomationRule, error) { var rule AutomationRule @@ -352,6 +357,7 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u } actionSvc := NewActionServiceWithWorker(s.db, s.worker) + actionSvc.SetSearchIndexer(s.searchIndexer) logSvc := NewExecutionLogService(s.db) for _, rule := range rules {