Files
gochat/internal/automation/service.go
T

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" }