From 4e2855958dbd7e05d3653409440c512c2468a4e4 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 09:32:25 +0800 Subject: [PATCH] feat(automation): record rule execution outcomes --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 41 +++-- internal/automation/execution_log_service.go | 30 +++- internal/automation/model.go | 17 +- internal/automation/service.go | 13 +- internal/automation/service_test.go | 164 ++++++++++++++++++- 5 files changed, 238 insertions(+), 27 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 186b7a22..eb908086 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest implementation checkpoint: `feat(automation): align rule trigger coverage`. -- Latest documentation checkpoint: this checkpoint, recorded with the B9.1b implementation. -- Worktree status at this implementation checkpoint: B9.1b Chatwoot listener trigger coverage is implemented; next active slice is B9.1c execution logs/stop-on-match, followed by B9.1d webhook/email transcript retries and B9.2 macro execution parity. +- Latest implementation checkpoint: `feat(automation): record rule execution outcomes`. +- Latest documentation checkpoint: this checkpoint, recorded with the B9.1c implementation. +- Worktree status at this implementation checkpoint: B9.1c execution outcome logging is implemented; next active slice is B9.1d retryable webhook/email transcript actions, followed by B9.2 macro execution parity. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -44,12 +44,11 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | B9.1c | Automation execution logs, failed-action records, and stop-on-match semantics are explicit and tested. | Rule service/action tests for matched, skipped, failed, and stop-on-match flows. | -| 2 | B9.1d | Webhook and email transcript actions are timeout-bound, retryable, and observable through the worker/action boundary. | Fake HTTP/mailer tests and log/retry metadata assertions. | -| 3 | B9.2 | Macro CRUD and macro execute side effects match Chatwoot frontend expectations. | Macro handler/service tests that reload conversations/messages after execution. | -| 4 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. | -| 5 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. | -| 6 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | +| 1 | B9.1d | Webhook and email transcript actions are timeout-bound, retryable, and observable through the worker/action boundary. | Fake HTTP/mailer tests and log/retry metadata assertions. | +| 2 | B9.2 | Macro CRUD and macro execute side effects match Chatwoot frontend expectations. | Macro handler/service tests that reload conversations/messages after execution. | +| 3 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. | +| 4 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. | +| 5 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | ## Execution Snapshot @@ -139,6 +138,7 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(automation): align automation rule payloads` | Completed B9.1a automation-rule CRUD contract parity for the reused Chatwoot frontend: list/show/update/clone now return `{ payload }`, create returns the raw rule object, delete returns empty `200 OK`, conditions accept `attribute_key` and Chatwoot operators such as `equal_to`, action params accept frontend arrays, responses serialize `attribute_key`, array `action_params`, Unix `created_on`, and update/delete/clone are account-scoped. | `go test ./internal/handler/api/v1 -run AutomationRule -count=1`; `go test ./internal/automation -run 'AutomationRule\|Condition\|Action' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1b trigger coverage, B9.1c execution logs/stop-on-match, and B9.2 macro payload/execute parity. | | `docs: detail automation trigger plan` | Fully landed the next B9 tracking contract: current baseline now points at `3403770`, the control board is ordered from B9.1b through B12, B9 status is `Doing`, and B9.1b explicitly follows the current Chatwoot listener events while excluding non-reference `contact_updated` automation triggers. | Documentation-only checkpoint; `git diff --check` passed before commit. | Start B9.1b implementation from the recorded listener/event contract. | | `feat(automation): align rule trigger coverage` | Completed B9.1b listener/event coverage: automation rules now honor Chatwoot skip rules for automation-origin events, auto-reply conversations, activity messages, and auto-reply emails; listener extraction works from event conversation ID, `conversation_id`, conversation payloads, and message payloads; conversation update/status/priority paths dispatch `changed_attributes`; initial conversation messages and provider webhook messages carry the message/conversation data needed by automation. | `go test ./internal/automation -run 'AutomationRuleListener\|MatchAndExecute\|Condition' -count=1`; `go test ./internal/service -run 'Conversation.*Automation\|Message.*Automation\|ConversationService' -count=1`; `go test ./internal/handler/webhook -run 'Incoming\|Webhook' -count=1`; `go test ./internal/automation -count=1`; `go test ./internal/service -count=1`; `go test ./internal/handler/webhook -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1c execution logs and stop-on-match behavior, then B9.1d retryable webhook/email transcript actions and B9.2 macro parity. | +| `feat(automation): record rule execution outcomes` | Completed B9.1c observability/parity guard: rule evaluations now record skipped condition outcomes, condition failures, event names, aggregate action counts, and per-action success/failure results. Matching rules continue executing in ID order without stop-on-match, matching the current Chatwoot listener loop, and failed actions do not prevent later actions from running. | `go test ./internal/automation -run 'AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/automation -count=1`; `go test ./internal/handler/webhook -run 'Incoming\|Webhook' -count=1`; `go test ./internal/service -run 'Conversation.*Automation\|Message.*Automation\|ConversationService' -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1d retryable webhook/email transcript actions, then B9.2 macro payload/execute parity. | ## Next Slice Contract @@ -410,7 +410,7 @@ B9 automation and macro execution breakdown: | --- | --- | --- | --- | --- | | B9.1a | Align automation-rule CRUD request/response envelopes, condition/action validation, and rule status toggles. | Chatwoot automation rules controller/model and dashboard automation client. | Handler tests for index/show/create/update/delete/toggle payloads and validation failures. | Done by `feat(automation): align automation rule payloads` | | B9.1b | Implement real rule trigger coverage for Chatwoot automation events: conversation created/updated/opened/resolved and message created. Provider webhook persistence must dispatch into those same conversation/message events. `contact_updated` is not a current Chatwoot automation trigger and stays out unless the local reference/frontend proves otherwise. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/listeners/base_listener.rb`, existing `channel.Dispatcher` events. | Listener tests prove matching rules execute once per event with account/inbox filters, changed attributes, provider-event reachability, and Chatwoot skip rules. | Done by `feat(automation): align rule trigger coverage` | -| B9.1c | Persist execution logs and stop-on-match behavior, including failed action records. | Chatwoot automation execution/log models and services. | Service tests cover success, skipped, failed, retryable, and stop-on-match flows. | Todo | +| B9.1c | Persist execution logs and stop-on-match behavior, including failed action records. The current Chatwoot listener runs every matching rule, so Go explicitly preserves no-stop-on-match behavior and records it in tests. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/services/automation_rules/action_service.rb`, Go execution log service. | Service tests cover success, skipped, failed/partial, per-action result records, and all-matching-rules execution. | Done by `feat(automation): record rule execution outcomes` | | B9.1d | Make webhook and email transcript actions timeout-bound, retryable, and observable. | Chatwoot action services/jobs and Phase 5 worker plan. | Tests use fake HTTP/mailer boundaries and assert retry/log metadata. | Todo | | B9.2a | Align macro CRUD payloads, visibility rules, and validation. | Chatwoot macros controller/model and dashboard macro client. | Handler tests for raw macro payloads, ownership, account scoping, and delete status. | Todo | | B9.2b | Execute macro actions against real conversations: labels, status, priority, assignee, team, custom attributes, private notes, and attachments where supported. | Chatwoot macro execution service and conversation/message controllers. | Macro execute tests reload conversation/message rows and assert Chatwoot-shaped response payloads. | Todo | @@ -511,6 +511,26 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... git diff --check ``` +B9.1c current checkpoint: + +- Automation execution logs now include `event_name`, aggregate action counts, and JSON `action_results` for each action in a matched rule. +- Rules whose event matches but conditions do not match now write `skipped` execution records with `conditions_not_matched`, giving the B9 tracker explicit skipped-flow evidence. +- Condition matcher errors are logged as failed rule evaluations instead of disappearing into process logs only. +- Failed actions record per-action errors while later actions still run, matching Chatwoot `AutomationRules::ActionService#perform` rescue-and-continue behavior. +- The current Chatwoot listener loops through every matching rule; Go preserves this no-stop-on-match behavior and has regression coverage proving two matching rules both execute. +- B9 remains `Doing` because B9.1d retryable webhook/email transcript actions and B9.2 macro parity are still active. + +B9.1c verification: + +```bash +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -run 'AutomationRuleService_MatchAndExecute|ExecutionLogService' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/webhook -run 'Incoming|Webhook' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'Conversation.*Automation|Message.*Automation|ConversationService' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... +git diff --check +``` + B10 admin and limits breakdown: | Step | Implementation target | Reference source | Required tests | Status | @@ -1157,3 +1177,4 @@ Verification milestone gates: - 2026-06-05: B9.1a automation-rule CRUD checkpoint committed as `feat(automation): align automation rule payloads`; automation-rule list/show/update/clone now return Chatwoot `{ payload }`, create returns the raw rule object, delete returns empty `200 OK`, frontend `attribute_key`/`equal_to` conditions and array `action_params` are accepted, responses serialize Chatwoot field names and Unix `created_on`, and get/update/delete/clone are account-scoped. Focused automation handler/service tests, handler and automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1b trigger coverage and action side-effect parity. - 2026-06-05: B9 planning tracker was fully landed for the next handoff: current baseline points at `3403770 feat(automation): align automation rule payloads`, the ordered control board now starts at B9.1b and runs through B12, B9 is marked `Doing`, and B9.1b records the exact Chatwoot listener contract. The current reference listener only subscribes to conversation created/updated/opened/resolved and message created, so `contact_updated` is explicitly not an automation trigger unless a later local reference/frontend audit proves otherwise. - 2026-06-05: B9.1b automation trigger checkpoint committed as `feat(automation): align rule trigger coverage`; automation listener now covers all current Chatwoot reference events, skips automation-origin/auto-reply/activity auto-reply-email cases, extracts account/conversation context from message and conversation event payloads, conversation update/status/priority paths dispatch `changed_attributes`, initial conversation messages dispatch full message-created context, and provider webhook persistence has a regression proving message-created automation reachability. Focused automation/service/webhook tests, package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1c execution log/stop-on-match parity. +- 2026-06-05: B9.1c execution outcome checkpoint committed as `feat(automation): record rule execution outcomes`; automation evaluations now persist event names, skipped condition outcomes, condition errors, per-action success/failure JSON, and aggregate action counts. Failed actions continue to later actions, and every matching rule still executes in ID order to match the current Chatwoot listener's no-stop-on-match loop. Focused automation tests, automation package tests, relevant service/webhook regressions, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1d retryable webhook/email transcript action delivery. diff --git a/internal/automation/execution_log_service.go b/internal/automation/execution_log_service.go index e3671545..0b50bb52 100644 --- a/internal/automation/execution_log_service.go +++ b/internal/automation/execution_log_service.go @@ -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" -) \ No newline at end of file + ExecutionStatusSkipped = "skipped" +) diff --git a/internal/automation/model.go b/internal/automation/model.go index 27363faf..d3deaede 100644 --- a/internal/automation/model.go +++ b/internal/automation/model.go @@ -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" } diff --git a/internal/automation/service.go b/internal/automation/service.go index 56d0aed5..a785e20f 100644 --- a/internal/automation/service.go +++ b/internal/automation/service.go @@ -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) } } diff --git a/internal/automation/service_test.go b/internal/automation/service_test.go index c5008510..2085e9d7 100644 --- a/internal/automation/service_test.go +++ b/internal/automation/service_test.go @@ -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)