Files
gochat/internal/automation/service.go
T
2026-06-04 15:44:48 +08:00

240 lines
8.2 KiB
Go

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