feat(automation): record rule execution outcomes
This commit is contained in:
@@ -2,6 +2,9 @@ package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// ===========================
|
||||
@@ -15,6 +18,13 @@ type ExecutionLogService struct {
|
||||
db DBProvider
|
||||
}
|
||||
|
||||
// ActionExecutionResult records the result of one action within a rule execution.
|
||||
type ActionExecutionResult struct {
|
||||
ActionName string `json:"action_name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewExecutionLogService creates a new ExecutionLogService.
|
||||
func NewExecutionLogService(db DBProvider) *ExecutionLogService {
|
||||
return &ExecutionLogService{db: db}
|
||||
@@ -22,18 +32,33 @@ func NewExecutionLogService(db DBProvider) *ExecutionLogService {
|
||||
|
||||
// LogRuleExecution records an automation rule execution event.
|
||||
func (s *ExecutionLogService) LogRuleExecution(ctx context.Context, accountID, ruleID, conversationID uint, status string, actionsExecuted, actionsFailed int, errorMsg string) error {
|
||||
return s.LogRuleExecutionWithResults(ctx, accountID, ruleID, conversationID, "", status, actionsExecuted, actionsFailed, errorMsg, nil)
|
||||
}
|
||||
|
||||
// LogRuleExecutionWithResults records a rule evaluation with optional event name and per-action results.
|
||||
func (s *ExecutionLogService) LogRuleExecutionWithResults(ctx context.Context, accountID, ruleID, conversationID uint, eventName, status string, actionsExecuted, actionsFailed int, errorMsg string, actionResults []ActionExecutionResult) error {
|
||||
resultsJSON := datatypes.JSON([]byte("[]"))
|
||||
if actionResults != nil {
|
||||
payload, err := json.Marshal(actionResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resultsJSON = datatypes.JSON(payload)
|
||||
}
|
||||
record := &AutomationExecution{
|
||||
AccountID: accountID,
|
||||
RuleID: ruleID,
|
||||
ConversationID: conversationID,
|
||||
EventName: eventName,
|
||||
Status: status,
|
||||
ActionsExecuted: actionsExecuted,
|
||||
ActionsFailed: actionsFailed,
|
||||
ActionResults: resultsJSON,
|
||||
ErrorMessage: errorMsg,
|
||||
}
|
||||
return s.db.DB().WithContext(ctx).Select(
|
||||
"AccountID", "RuleID", "ConversationID",
|
||||
"Status", "ActionsExecuted", "ActionsFailed", "ErrorMessage",
|
||||
"EventName", "Status", "ActionsExecuted", "ActionsFailed", "ActionResults", "ErrorMessage",
|
||||
).Create(record).Error
|
||||
}
|
||||
|
||||
@@ -103,4 +128,5 @@ const (
|
||||
ExecutionStatusSuccess = "success"
|
||||
ExecutionStatusPartial = "partial" // some actions succeeded, some failed
|
||||
ExecutionStatusFailed = "failed"
|
||||
)
|
||||
ExecutionStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// ===========================
|
||||
@@ -155,13 +156,15 @@ func (CsatSurveyResponse) TableName() string { return "csat_survey_responses" }
|
||||
// gochat adds this for debugging, monitoring, and compliance.
|
||||
type AutomationExecution struct {
|
||||
model.Base
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
RuleID uint `gorm:"index;not null" json:"rule_id"`
|
||||
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
|
||||
Status string `gorm:"size:50;not null" json:"status"` // success, partial, failed
|
||||
ActionsExecuted int `gorm:"default:0" json:"actions_executed"`
|
||||
ActionsFailed int `gorm:"default:0" json:"actions_failed"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
RuleID uint `gorm:"index;not null" json:"rule_id"`
|
||||
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
|
||||
EventName string `gorm:"size:100;index" json:"event_name,omitempty"`
|
||||
Status string `gorm:"size:50;not null" json:"status"` // success, partial, failed, skipped
|
||||
ActionsExecuted int `gorm:"default:0" json:"actions_executed"`
|
||||
ActionsFailed int `gorm:"default:0" json:"actions_failed"`
|
||||
ActionResults datatypes.JSON `gorm:"type:jsonb" json:"action_results,omitempty"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
func (AutomationExecution) TableName() string { return "automation_executions" }
|
||||
|
||||
@@ -234,15 +234,22 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
}
|
||||
|
||||
actionSvc := NewActionService(s.db)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -253,6 +260,7 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
actionsExecuted := 0
|
||||
actionsFailed := 0
|
||||
var firstErrorMsg string
|
||||
actionResults := make([]ActionExecutionResult, 0, len(rule.Actions))
|
||||
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)
|
||||
@@ -260,21 +268,22 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
if firstErrorMsg == "" {
|
||||
firstErrorMsg = err.Error()
|
||||
}
|
||||
actionResults = append(actionResults, ActionExecutionResult{ActionName: action.ActionName, Status: ExecutionStatusFailed, Error: err.Error()})
|
||||
// Continue executing remaining actions (Chatwoot pattern)
|
||||
} else {
|
||||
actionsExecuted++
|
||||
actionResults = append(actionResults, ActionExecutionResult{ActionName: action.ActionName, Status: ExecutionStatusSuccess})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -82,12 +83,12 @@ func TestAutomationRuleService_Update(t *testing.T) {
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_created",
|
||||
Name: "Original name",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{},
|
||||
Active: true,
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_created",
|
||||
Name: "Original name",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{},
|
||||
Active: true,
|
||||
}
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("setup: failed to create rule: %v", err)
|
||||
@@ -210,6 +211,157 @@ func TestAutomationRuleService_ListActiveByAccountAndEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_LogsSkippedRules(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversationWithDetails(db, t, accountID, inboxID, contactID, "open", "low", "web", 0)
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_updated",
|
||||
Name: "skip when status mismatch",
|
||||
Conditions: Conditions{{
|
||||
Attribute: "status",
|
||||
FilterOperator: "equal",
|
||||
Values: []string{"resolved"},
|
||||
}},
|
||||
Actions: Actions{{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"should-not-run"}}}},
|
||||
Active: true,
|
||||
}
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("create rule: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_updated", conversationID, map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("match and execute: %v", err)
|
||||
}
|
||||
|
||||
logs, err := NewExecutionLogService(dbProvider).ListRuleExecutions(context.Background(), accountID, rule.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list executions: %v", err)
|
||||
}
|
||||
if len(logs) != 1 {
|
||||
t.Fatalf("expected 1 skipped log, got %d", len(logs))
|
||||
}
|
||||
if logs[0].Status != ExecutionStatusSkipped {
|
||||
t.Fatalf("expected skipped status, got %s", logs[0].Status)
|
||||
}
|
||||
if logs[0].EventName != "conversation_updated" {
|
||||
t.Fatalf("expected event name conversation_updated, got %s", logs[0].EventName)
|
||||
}
|
||||
if logs[0].ActionsExecuted != 0 || logs[0].ActionsFailed != 0 {
|
||||
t.Fatalf("expected zero action counts, got executed=%d failed=%d", logs[0].ActionsExecuted, logs[0].ActionsFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_RecordsPerActionFailuresAndContinues(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_created",
|
||||
Name: "partial action log",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{
|
||||
{ActionName: "send_message", ActionParams: map[string]interface{}{}},
|
||||
{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"after-failure"}}},
|
||||
},
|
||||
Active: true,
|
||||
}
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("create rule: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("match and execute: %v", err)
|
||||
}
|
||||
|
||||
logs, err := NewExecutionLogService(dbProvider).ListRuleExecutions(context.Background(), accountID, rule.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list executions: %v", err)
|
||||
}
|
||||
if len(logs) != 1 {
|
||||
t.Fatalf("expected 1 execution log, got %d", len(logs))
|
||||
}
|
||||
log := logs[0]
|
||||
if log.Status != ExecutionStatusPartial {
|
||||
t.Fatalf("expected partial status, got %s", log.Status)
|
||||
}
|
||||
if log.ActionsExecuted != 1 || log.ActionsFailed != 1 {
|
||||
t.Fatalf("expected executed=1 failed=1, got executed=%d failed=%d", log.ActionsExecuted, log.ActionsFailed)
|
||||
}
|
||||
if log.ErrorMessage == "" {
|
||||
t.Fatalf("expected first action error message")
|
||||
}
|
||||
var results []ActionExecutionResult
|
||||
if err := json.Unmarshal(log.ActionResults, &results); err != nil {
|
||||
t.Fatalf("unmarshal action results: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 action result records, got %d", len(results))
|
||||
}
|
||||
if results[0].ActionName != "send_message" || results[0].Status != ExecutionStatusFailed || results[0].Error == "" {
|
||||
t.Fatalf("unexpected failed action result: %#v", results[0])
|
||||
}
|
||||
if results[1].ActionName != "add_label" || results[1].Status != ExecutionStatusSuccess {
|
||||
t.Fatalf("unexpected success action result: %#v", results[1])
|
||||
}
|
||||
|
||||
var label ConversationLabel
|
||||
if err := db.Where("conversation_id = ? AND label = ?", conversationID, "after-failure").First(&label).Error; err != nil {
|
||||
t.Fatalf("expected second action to continue and add label: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_RunsAllMatchingRulesWithoutStopOnMatch(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rules := []*AutomationRule{
|
||||
{AccountID: accountID, EventName: "conversation_created", Name: "first match", Conditions: Conditions{}, Actions: Actions{{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"first-match"}}}}, Active: true},
|
||||
{AccountID: accountID, EventName: "conversation_created", Name: "second match", Conditions: Conditions{}, Actions: Actions{{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"second-match"}}}}, Active: true},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("create rule %s: %v", rule.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("match and execute: %v", err)
|
||||
}
|
||||
|
||||
var logCount int64
|
||||
if err := db.Model(&AutomationExecution{}).Where("conversation_id = ? AND status = ?", conversationID, ExecutionStatusSuccess).Count(&logCount).Error; err != nil {
|
||||
t.Fatalf("count logs: %v", err)
|
||||
}
|
||||
if logCount != 2 {
|
||||
t.Fatalf("expected both matching rules to execute, got %d logs", logCount)
|
||||
}
|
||||
|
||||
for _, labelName := range []string{"first-match", "second-match"} {
|
||||
var label ConversationLabel
|
||||
if err := db.Where("conversation_id = ? AND label = ?", conversationID, labelName).First(&label).Error; err != nil {
|
||||
t.Fatalf("expected label %s from matching rule: %v", labelName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_ToggleActive(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
||||
|
||||
Reference in New Issue
Block a user