347 lines
12 KiB
Go
347 lines
12 KiB
Go
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" }
|