feat(automation): record rule execution outcomes

This commit is contained in:
2026-06-05 09:32:25 +08:00
parent f4c56bf109
commit 4e2855958d
5 changed files with 238 additions and 27 deletions
+28 -2
View File
@@ -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"
)
+10 -7
View File
@@ -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" }
+11 -2
View File
@@ -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)
}
}
+158 -6
View File
@@ -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)