package automation import ( "context" "fmt" "time" 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 } // NewAutomationRuleService creates a new AutomationRuleService. func NewAutomationRuleService(db DBProvider) *AutomationRuleService { return &AutomationRuleService{db: 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 } // 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 { // 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 { // 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 } // 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 } // 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 } // 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 } // 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 := NewActionService(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) continue } if !matched { applogger.L().Debugf("rule %d did not match conditions for conversation %d", rule.ID, conversationID) 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 for _, action := range rule.Actions { if err := actionSvc.Execute(ctx, accountID, conversationID, action, ActionSourceAutomation, rule.ID); 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() } // Continue executing remaining actions (Chatwoot pattern) } else { actionsExecuted++ } } // Log execution for audit trail logSvc := NewExecutionLogService(s.db) status := ExecutionStatusSuccess if actionsFailed > 0 && actionsExecuted > 0 { status = ExecutionStatusPartial } else if actionsFailed > 0 && actionsExecuted == 0 { status = ExecutionStatusFailed } if logErr := logSvc.LogRuleExecution(ctx, accountID, rule.ID, conversationID, status, actionsExecuted, actionsFailed, firstErrorMsg); logErr != nil { applogger.L().Warnf("failed to log automation execution for rule %d: %v", rule.ID, logErr) } } return nil } // 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" }