package automation import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // DBProvider provides database access — implemented by app.App to break the import cycle. type DBProvider interface { DB() *gorm.DB } // AutomationRuleService provides CRUD + condition matching + action execution for automation rules. // Reference: Chatwoot AutomationRules::ActionService + ConditionsFilterService pattern type AutomationRuleService struct { db DBProvider worker *worker.WorkerPool } // NewAutomationRuleService creates a new AutomationRuleService. func NewAutomationRuleService(db DBProvider) *AutomationRuleService { return &AutomationRuleService{db: db} } func NewAutomationRuleServiceWithWorker(db DBProvider, wp *worker.WorkerPool) *AutomationRuleService { s := NewAutomationRuleService(db) s.SetWorkerPool(wp) return s } func (s *AutomationRuleService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterActionDeliveryJobs(wp, s.db) } // GetByID retrieves an automation rule by ID. func (s *AutomationRuleService) GetByID(ctx context.Context, id uint) (*AutomationRule, error) { var rule AutomationRule if err := s.db.DB().WithContext(ctx).First(&rule, id).Error; err != nil { return nil, err } return &rule, nil } // GetByIDForAccount retrieves an automation rule scoped to an account. func (s *AutomationRuleService) GetByIDForAccount(ctx context.Context, accountID, id uint) (*AutomationRule, error) { var rule AutomationRule if err := s.db.DB().WithContext(ctx). Where("account_id = ?", accountID). First(&rule, id).Error; err != nil { return nil, err } return &rule, nil } // ListByAccount retrieves all automation rules for an account. func (s *AutomationRuleService) ListByAccount(ctx context.Context, accountID uint) ([]AutomationRule, error) { var rules []AutomationRule if err := s.db.DB().WithContext(ctx). Where("account_id = ?", accountID). Order("id ASC"). Find(&rules).Error; err != nil { return nil, err } return rules, nil } // ListActiveByAccountAndEvent retrieves active automation rules for a given account and event. // Reference: Chatwoot AutomationRuleListener loads rules matching event_name func (s *AutomationRuleService) ListActiveByAccountAndEvent(ctx context.Context, accountID uint, eventName string) ([]AutomationRule, error) { var rules []AutomationRule if err := s.db.DB().WithContext(ctx). Where("account_id = ? AND event_name = ? AND active = ?", accountID, eventName, true). Order("id ASC"). Find(&rules).Error; err != nil { return nil, err } return rules, nil } // Create creates a new automation rule. // New rules default to active unless explicitly set to inactive. func (s *AutomationRuleService) Create(ctx context.Context, rule *AutomationRule) error { rule.Conditions = NormalizeConditions(rule.Conditions) // Validate conditions before saving if err := ValidateConditions(rule.Conditions); err != nil { return fmt.Errorf("invalid conditions: %w", err) } // Validate actions before saving if err := ValidateActions(rule.Actions); err != nil { return fmt.Errorf("invalid actions: %w", err) } // Use Select to force all fields including zero-value bool Active=false // Without Select, GORM skips zero-value fields and uses column defaults. return s.db.DB().WithContext(ctx).Select( "AccountID", "EventName", "Name", "Description", "Conditions", "Actions", "Active", ).Create(rule).Error } // Update updates an existing automation rule. func (s *AutomationRuleService) Update(ctx context.Context, rule *AutomationRule) error { rule.Conditions = NormalizeConditions(rule.Conditions) // Validate conditions before saving if err := ValidateConditions(rule.Conditions); err != nil { return fmt.Errorf("invalid conditions: %w", err) } // Validate actions before saving if err := ValidateActions(rule.Actions); err != nil { return fmt.Errorf("invalid actions: %w", err) } return s.db.DB().WithContext(ctx).Save(rule).Error } // UpdateForAccount updates an existing automation rule within account scope. func (s *AutomationRuleService) UpdateForAccount(ctx context.Context, accountID uint, rule *AutomationRule) error { var existing AutomationRule if err := s.db.DB().WithContext(ctx). Where("account_id = ?", accountID). First(&existing, rule.ID).Error; err != nil { return err } existing.Name = rule.Name existing.Description = rule.Description existing.EventName = rule.EventName existing.Conditions = NormalizeConditions(rule.Conditions) existing.Actions = rule.Actions existing.Active = rule.Active return s.Update(ctx, &existing) } // Delete soft-deletes an automation rule. func (s *AutomationRuleService) Delete(ctx context.Context, id uint) error { return s.db.DB().WithContext(ctx).Delete(&AutomationRule{}, id).Error } // DeleteForAccount deletes an automation rule scoped to an account. func (s *AutomationRuleService) DeleteForAccount(ctx context.Context, accountID, id uint) error { rule, err := s.GetByIDForAccount(ctx, accountID, id) if err != nil { return err } return s.db.DB().WithContext(ctx).Delete(rule).Error } // Clone duplicates an automation rule, creating a new copy with "(copy)" suffix and Active=false. // Reference: Chatwoot AutomationRulesController#clone func (s *AutomationRuleService) Clone(ctx context.Context, id uint) (*AutomationRule, error) { original, err := s.GetByID(ctx, id) if err != nil { return nil, err } cloned := &AutomationRule{ AccountID: original.AccountID, EventName: original.EventName, Name: original.Name + " (copy)", Description: original.Description, Conditions: original.Conditions, Actions: original.Actions, Active: false, } if err := s.db.DB().WithContext(ctx).Create(cloned).Error; err != nil { return nil, err } return cloned, nil } // CloneForAccount duplicates an automation rule scoped to an account. func (s *AutomationRuleService) CloneForAccount(ctx context.Context, accountID, id uint) (*AutomationRule, error) { original, err := s.GetByIDForAccount(ctx, accountID, id) if err != nil { return nil, err } cloned := &AutomationRule{ AccountID: original.AccountID, EventName: original.EventName, Name: original.Name + " (copy)", Description: original.Description, Conditions: original.Conditions, Actions: original.Actions, Active: false, } if err := s.db.DB().WithContext(ctx).Create(cloned).Error; err != nil { return nil, err } return cloned, nil } // ToggleActive toggles the active state of an automation rule and sets the corresponding timestamp. func (s *AutomationRuleService) ToggleActive(ctx context.Context, id uint, active bool) error { now := time.Now() updates := map[string]interface{}{ "active": active, } if active { updates["active_at"] = &now updates["inactive_at"] = nil } else { updates["inactive_at"] = &now updates["active_at"] = nil } return s.db.DB().WithContext(ctx). Model(&AutomationRule{}). Where("id = ?", id). Updates(updates).Error } // ToggleActiveForAccount toggles the active state within account scope. func (s *AutomationRuleService) ToggleActiveForAccount(ctx context.Context, accountID, id uint, active bool) error { if _, err := s.GetByIDForAccount(ctx, accountID, id); err != nil { return err } return s.ToggleActive(ctx, id, active) } // MatchAndExecute checks all active rules for the given event and account, // runs condition filters for each rule, and executes actions for matching rules. // Reference: Chatwoot AutomationRuleListener — loads rules, runs ConditionsFilterService, then ActionService func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID uint, eventName string, conversationID uint, eventData map[string]interface{}) error { rules, err := s.ListActiveByAccountAndEvent(ctx, accountID, eventName) if err != nil { return fmt.Errorf("failed to load automation rules: %w", err) } if len(rules) == 0 { applogger.L().Debugf("no active automation rules for account %d event %s", accountID, eventName) return nil } // Load the conversation for condition matching conversation, err := s.loadConversation(ctx, conversationID) if err != nil { return fmt.Errorf("failed to load conversation %d: %w", conversationID, err) } actionSvc := NewActionServiceWithWorker(s.db, s.worker) logSvc := NewExecutionLogService(s.db) for _, rule := range rules { matched, err := MatchConditions(ctx, s.db.DB(), rule.Conditions, conversation, eventData) if err != nil { applogger.L().Errorf("condition matching failed for rule %d: %v", rule.ID, err) if logErr := logSvc.LogRuleExecutionWithResults(ctx, accountID, rule.ID, conversationID, eventName, ExecutionStatusFailed, 0, 0, fmt.Sprintf("condition matching failed: %v", err), nil); logErr != nil { applogger.L().Warnf("failed to log automation condition error for rule %d: %v", rule.ID, logErr) } continue } if !matched { applogger.L().Debugf("rule %d did not match conditions for conversation %d", rule.ID, conversationID) if logErr := logSvc.LogRuleExecutionWithResults(ctx, accountID, rule.ID, conversationID, eventName, ExecutionStatusSkipped, 0, 0, "conditions_not_matched", nil); logErr != nil { applogger.L().Warnf("failed to log skipped automation rule %d: %v", rule.ID, logErr) } continue } applogger.L().Infof("automation rule %d matched for conversation %d, executing %d actions", rule.ID, conversationID, len(rule.Actions)) // Stamp automation_rule_id on actions for audit trail // Reference: Chatwoot AutomationRules::ActionService stamps automation_rule_id actionsExecuted := 0 actionsFailed := 0 var firstErrorMsg string actionResults := make([]ActionExecutionResult, 0, len(rule.Actions)) for _, action := range rule.Actions { actionForExecution := actionWithAutomationContext(action, eventName) result, err := actionSvc.ExecuteWithResult(ctx, accountID, conversationID, actionForExecution, ActionSourceAutomation, rule.ID) if err != nil { applogger.L().Errorf("action %s failed for rule %d on conversation %d: %v", action.ActionName, rule.ID, conversationID, err) actionsFailed++ if firstErrorMsg == "" { firstErrorMsg = err.Error() } actionResults = append(actionResults, result) // Continue executing remaining actions (Chatwoot pattern) } else { actionsExecuted++ actionResults = append(actionResults, result) } } // Log execution for audit trail status := ExecutionStatusSuccess if actionsFailed > 0 && actionsExecuted > 0 { status = ExecutionStatusPartial } else if actionsFailed > 0 && actionsExecuted == 0 { status = ExecutionStatusFailed } if logErr := logSvc.LogRuleExecutionWithResults(ctx, accountID, rule.ID, conversationID, eventName, status, actionsExecuted, actionsFailed, firstErrorMsg, actionResults); logErr != nil { applogger.L().Warnf("failed to log automation execution for rule %d: %v", rule.ID, logErr) } } return nil } func actionWithAutomationContext(action Action, eventName string) Action { params := map[string]interface{}{} for key, value := range action.ActionParams { params[key] = value } params["_event_name"] = eventName return Action{ActionName: action.ActionName, ActionParams: params} } // loadConversation loads a conversation from the database. func (s *AutomationRuleService) loadConversation(ctx context.Context, conversationID uint) (*ConversationForFilter, error) { var conv ConversationForFilter if err := s.db.DB().WithContext(ctx). Table("conversations"). Where("id = ?", conversationID). First(&conv).Error; err != nil { return nil, err } return &conv, nil } // ConversationForFilter is a lightweight struct used for condition matching. // It mirrors the conversation model fields needed by ConditionsFilterService. type ConversationForFilter struct { ID uint AccountID uint InboxID uint ContactID uint AssigneeID *uint Status string Priority string ChannelType string LastMessageAt *int64 Labels []string `gorm:"type:text;serializer:json"` // populated from conversation_labels join if needed } // TableName overrides for GORM queries. func (ConversationForFilter) TableName() string { return "conversations" }