From 5284e8c377349442dfa32bd812417bff0e7cab84 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:38:50 -0400 Subject: [PATCH] feat(workflow): add WorkflowRunnerService for event-driven workflow firing Ports the NestJS workflow-runner.service.ts to WordPress. Loads active Workflows for a trigger event (in position order via SQL), evaluates conditions via WorkflowEngine, dispatches matches to WorkflowExecutorService, and writes a workflow_log audit row per Workflow considered. - Honors stop_on_match (only when the workflow actually matches) - Catches executor failures so one bad workflow never blocks the rest (failure stamped on the log row via error_message; warn-logged when WP_DEBUG is on) - Null/blank conditions match all tickets (matches NestJS semantics) Follow-up PR will add the WP hook handlers that bridge escalated_ticket_created / _replied / etc. into run_for_event. --- includes/Services/WorkflowRunnerService.php | 180 +++++++++++++++ tests/Test_Workflow_Runner_Service.php | 242 ++++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 includes/Services/WorkflowRunnerService.php create mode 100644 tests/Test_Workflow_Runner_Service.php diff --git a/includes/Services/WorkflowRunnerService.php b/includes/Services/WorkflowRunnerService.php new file mode 100644 index 0000000..80028ca --- /dev/null +++ b/includes/Services/WorkflowRunnerService.php @@ -0,0 +1,180 @@ +engine = $engine ?? new WorkflowEngine; + $this->executor = $executor ?? new WorkflowExecutorService; + } + + /** + * Run workflows matching the given trigger event against a ticket. + * + * @param object $ticket Ticket row (as returned by Ticket::find). + * @return int Number of workflows that executed actions. + */ + public function run_for_event(string $trigger_event, object $ticket): int + { + $workflows = $this->find_active_by_trigger($trigger_event); + if (empty($workflows)) { + return 0; + } + + $condition_map = $this->ticket_to_condition_map($ticket); + $executed = 0; + + foreach ($workflows as $wf) { + $started_at = current_time('mysql'); + $conditions = $this->decode_json_array($wf->conditions); + $matched = $this->matches($conditions, $condition_map); + + $log_id = $this->create_log([ + 'workflow_id' => (int) $wf->id, + 'ticket_id' => (int) $ticket->id, + 'trigger_event' => $trigger_event, + 'conditions_matched' => $matched ? 1 : 0, + 'started_at' => $started_at, + 'created_at' => $started_at, + ]); + + if (! $matched) { + continue; + } + + try { + $result = $this->executor->execute($ticket, $wf->actions); + $this->update_log($log_id, [ + 'actions_executed' => wp_json_encode($result), + 'completed_at' => current_time('mysql'), + ]); + $executed++; + } catch (\Throwable $e) { + $this->update_log($log_id, [ + 'error_message' => $e->getMessage(), + 'completed_at' => current_time('mysql'), + ]); + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log(sprintf( + '[Escalated\\WorkflowRunner] workflow #%d (%s) failed on ticket #%d: %s', + $wf->id, + $wf->name, + $ticket->id, + $e->getMessage() + )); + } + } + + if (! empty($wf->stop_on_match)) { + break; + } + } + + return $executed; + } + + protected function find_active_by_trigger(string $trigger_event): array + { + global $wpdb; + $table = Workflow::table(); + + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} + WHERE trigger_event = %s AND is_active = 1 + ORDER BY position ASC, name ASC", + $trigger_event + ) + ) ?: []; + } + + protected function matches(?array $conditions, array $ticket_map): bool + { + // Null / empty conditions match everything (NestJS parity). + if (empty($conditions)) { + return true; + } + + return $this->engine->evaluateConditions($conditions, $ticket_map); + } + + protected function create_log(array $data): int + { + global $wpdb; + $table = Escalated::table('workflow_logs'); + $wpdb->insert($table, $data); + + return (int) $wpdb->insert_id; + } + + protected function update_log(int $log_id, array $data): void + { + if ($log_id <= 0) { + return; + } + global $wpdb; + $table = Escalated::table('workflow_logs'); + $wpdb->update($table, $data, ['id' => $log_id]); + } + + protected function decode_json_array($value): ?array + { + if (empty($value)) { + return null; + } + if (is_array($value)) { + return $value; + } + if (! is_string($value)) { + return null; + } + $decoded = json_decode($value, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * Flatten a ticket row into a string map for the condition evaluator. + * + * @return array + */ + protected function ticket_to_condition_map(object $ticket): array + { + $out = []; + foreach (get_object_vars($ticket) as $key => $val) { + if ($val === null || is_array($val) || is_object($val)) { + $out[$key] = ''; + + continue; + } + $out[$key] = (string) $val; + } + + return $out; + } +} diff --git a/tests/Test_Workflow_Runner_Service.php b/tests/Test_Workflow_Runner_Service.php new file mode 100644 index 0000000..ccfe4df --- /dev/null +++ b/tests/Test_Workflow_Runner_Service.php @@ -0,0 +1,242 @@ +runner = new WorkflowRunnerService; + $this->ticket_service = new TicketService; + $this->user_id = $this->factory->user->create(['role' => 'subscriber']); + } + + private function make_ticket(array $overrides = []): object + { + $defaults = [ + 'subject' => 'Test', + 'description' => 'Body', + 'priority' => 'low', + 'channel' => 'web', + ]; + + return $this->ticket_service->create($this->user_id, array_merge($defaults, $overrides)); + } + + private function create_workflow(array $data): int + { + global $wpdb; + $defaults = [ + 'name' => 'Test Workflow', + 'trigger_event' => 'ticket.created', + 'conditions' => null, + 'actions' => wp_json_encode([['type' => 'add_note', 'value' => 'auto']]), + 'is_active' => 1, + 'position' => 0, + 'stop_on_match' => 0, + 'created_at' => current_time('mysql'), + 'updated_at' => current_time('mysql'), + ]; + $wpdb->insert(Workflow::table(), array_merge($defaults, $data)); + + return (int) $wpdb->insert_id; + } + + private function count_logs(int $workflow_id = 0): int + { + global $wpdb; + $table = Escalated::table('workflow_logs'); + if ($workflow_id > 0) { + return (int) $wpdb->get_var( + $wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE workflow_id = %d", $workflow_id) + ); + } + + return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); + } + + public function test_run_for_event_with_no_matching_workflows_is_noop(): void + { + $ticket = $this->make_ticket(); + + $executed = $this->runner->run_for_event('ticket.created', $ticket); + + $this->assertEquals(0, $executed); + $this->assertEquals(0, $this->count_logs()); + } + + public function test_run_for_event_matches_and_executes(): void + { + $wf_id = $this->create_workflow([ + 'name' => 'Auto-note on create', + 'trigger_event' => 'ticket.created', + ]); + $ticket = $this->make_ticket(); + + $executed = $this->runner->run_for_event('ticket.created', $ticket); + + $this->assertEquals(1, $executed); + $this->assertEquals(1, $this->count_logs($wf_id)); + + global $wpdb; + $log_table = Escalated::table('workflow_logs'); + $log = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$log_table} WHERE workflow_id = %d", $wf_id) + ); + $this->assertNotNull($log); + $this->assertEquals(1, (int) $log->conditions_matched); + $this->assertNotEmpty($log->completed_at); + $this->assertEmpty($log->error_message); + $this->assertStringContainsString('add_note', $log->actions_executed); + } + + public function test_run_for_event_unmatched_logs_but_does_not_execute(): void + { + $wf_id = $this->create_workflow([ + 'name' => 'Only on closed', + 'trigger_event' => 'ticket.created', + 'conditions' => wp_json_encode([ + 'all' => [['field' => 'status', 'operator' => 'equals', 'value' => 'closed']], + ]), + ]); + $ticket = $this->make_ticket(); // status = open + + $executed = $this->runner->run_for_event('ticket.created', $ticket); + + $this->assertEquals(0, $executed); + $this->assertEquals(1, $this->count_logs($wf_id)); + + global $wpdb; + $log_table = Escalated::table('workflow_logs'); + $log = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$log_table} WHERE workflow_id = %d", $wf_id) + ); + $this->assertEquals(0, (int) $log->conditions_matched); + } + + public function test_run_for_event_respects_trigger_filter(): void + { + $wf_id = $this->create_workflow([ + 'name' => 'On-reply only', + 'trigger_event' => 'reply.created', + ]); + $ticket = $this->make_ticket(); + + $executed = $this->runner->run_for_event('ticket.created', $ticket); + + $this->assertEquals(0, $executed); + $this->assertEquals(0, $this->count_logs($wf_id)); + } + + public function test_run_for_event_skips_inactive_workflows(): void + { + $wf_id = $this->create_workflow([ + 'name' => 'Disabled', + 'trigger_event' => 'ticket.created', + 'is_active' => 0, + ]); + $ticket = $this->make_ticket(); + + $executed = $this->runner->run_for_event('ticket.created', $ticket); + + $this->assertEquals(0, $executed); + $this->assertEquals(0, $this->count_logs($wf_id)); + } + + public function test_stop_on_match_halts_after_first_match(): void + { + $first_id = $this->create_workflow([ + 'name' => 'First', + 'trigger_event' => 'ticket.created', + 'position' => 0, + 'stop_on_match' => 1, + 'actions' => wp_json_encode([['type' => 'change_priority', 'value' => 'high']]), + ]); + $second_id = $this->create_workflow([ + 'name' => 'Second', + 'trigger_event' => 'ticket.created', + 'position' => 1, + 'stop_on_match' => 0, + 'actions' => wp_json_encode([['type' => 'change_priority', 'value' => 'urgent']]), + ]); + $ticket = $this->make_ticket(); + + $this->runner->run_for_event('ticket.created', $ticket); + + // First log exists; second doesn't (we stopped). + $this->assertEquals(1, $this->count_logs($first_id)); + $this->assertEquals(0, $this->count_logs($second_id)); + } + + public function test_stop_on_match_only_applies_on_match(): void + { + $first_id = $this->create_workflow([ + 'name' => 'First (non-matching)', + 'trigger_event' => 'ticket.created', + 'position' => 0, + 'stop_on_match' => 1, + 'conditions' => wp_json_encode([ + 'all' => [['field' => 'status', 'operator' => 'equals', 'value' => 'closed']], + ]), + ]); + $second_id = $this->create_workflow([ + 'name' => 'Second', + 'trigger_event' => 'ticket.created', + 'position' => 1, + 'stop_on_match' => 0, + ]); + $ticket = $this->make_ticket(); + + $this->runner->run_for_event('ticket.created', $ticket); + + // Both logs exist since first didn't match. + $this->assertEquals(1, $this->count_logs($first_id)); + $this->assertEquals(1, $this->count_logs($second_id)); + } + + public function test_executes_in_position_order(): void + { + $this->create_workflow([ + 'name' => 'Second by position', + 'trigger_event' => 'ticket.created', + 'position' => 10, + 'actions' => wp_json_encode([['type' => 'change_priority', 'value' => 'urgent']]), + ]); + $this->create_workflow([ + 'name' => 'First by position', + 'trigger_event' => 'ticket.created', + 'position' => 1, + 'actions' => wp_json_encode([['type' => 'change_priority', 'value' => 'high']]), + ]); + $ticket = $this->make_ticket(); + + $this->runner->run_for_event('ticket.created', $ticket); + + // Later workflow wins since the high→urgent happens last. + $fresh = \Escalated\Models\Ticket::find($ticket->id); + $this->assertEquals('urgent', $fresh->priority); + } +}