From 4c00cf2e783282b506111e057ee5b9b487025eb3 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:27:16 -0400 Subject: [PATCH] feat(workflow): add WorkflowExecutorService for action dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the NestJS workflow-executor.service.ts to WordPress. The existing WorkflowEngine only evaluates conditions; this service parses the JSON action array stored on workflow.actions and dispatches each entry against TicketService / AssignmentService. Action catalog: change_priority, change_status, assign_agent, set_department, add_tag, remove_tag, add_note, insert_canned_reply. {{field}} placeholders in canned replies are interpolated via WorkflowEngine::interpolateVariables. One failing action does not halt the others (warn-logged when WP_DEBUG is on). Unknown action types skip. Malformed JSON returns an empty action list. Follow-up PR will add the WorkflowRunner (loads matching workflows, evaluates, writes WorkflowLog) and the hook that bridges escalated_ticket_created / _replied / etc. into processEvent. The existing AutomationRunner stays as-is — Automations and Workflows are distinct product surfaces. --- includes/Services/WorkflowExecutorService.php | 269 +++++++++++++++++ tests/Test_Workflow_Executor_Service.php | 275 ++++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 includes/Services/WorkflowExecutorService.php create mode 100644 tests/Test_Workflow_Executor_Service.php diff --git a/includes/Services/WorkflowExecutorService.php b/includes/Services/WorkflowExecutorService.php new file mode 100644 index 0000000..ecd532a --- /dev/null +++ b/includes/Services/WorkflowExecutorService.php @@ -0,0 +1,269 @@ +ticket_service = $ticket_service ?? new TicketService; + $this->assignment_service = $assignment_service ?? new AssignmentService; + } + + /** + * Execute every action in $actions_json against $ticket. Returns + * the parsed action list so the caller (typically WorkflowRunner) + * can serialize it into a workflow_log row. + * + * @param object $ticket Ticket row as returned by Ticket::find. + * @param string|null $actions_json JSON-encoded action array. + * @return array parsed actions (empty on malformed input). + */ + public function execute(object $ticket, ?string $actions_json): array + { + $actions = $this->parse_actions($actions_json); + foreach ($actions as $action) { + try { + $this->dispatch($ticket, $action); + } catch (\Throwable $e) { + $this->log_action_failure($ticket, $action, $e); + } + } + + return $actions; + } + + protected function parse_actions(?string $actions_json): array + { + if (empty($actions_json)) { + return []; + } + $decoded = json_decode($actions_json, true); + + return is_array($decoded) ? $decoded : []; + } + + protected function dispatch(object $ticket, array $action): void + { + $type = $action['type'] ?? ''; + $value = (string) ($action['value'] ?? ''); + $ticket_id = (int) $ticket->id; + + switch ($type) { + case 'change_priority': + $this->change_priority($ticket_id, $value); + break; + + case 'change_status': + $this->change_status($ticket_id, $value); + break; + + case 'assign_agent': + $this->assign_agent($ticket_id, $value); + break; + + case 'set_department': + $this->set_department($ticket_id, $value); + break; + + case 'add_tag': + $this->add_tag($ticket_id, $value); + break; + + case 'remove_tag': + $this->remove_tag($ticket_id, $value); + break; + + case 'add_note': + $this->add_note($ticket_id, $value); + break; + + case 'insert_canned_reply': + $this->insert_canned_reply($ticket, $value); + break; + + default: + $this->log_debug(sprintf('unknown action type: %s', $type)); + } + } + + protected function change_priority(int $ticket_id, string $value): void + { + if ($value === '') { + return; + } + $this->ticket_service->change_priority($ticket_id, $value); + } + + protected function change_status(int $ticket_id, string $value): void + { + if ($value === '') { + return; + } + $this->ticket_service->change_status($ticket_id, $value); + } + + protected function assign_agent(int $ticket_id, string $value): void + { + $agent_id = (int) $value; + if ($agent_id <= 0) { + return; + } + $this->assignment_service->assign($ticket_id, $agent_id); + } + + protected function set_department(int $ticket_id, string $value): void + { + $department_id = (int) $value; + if ($department_id <= 0) { + return; + } + $this->ticket_service->change_department($ticket_id, $department_id); + } + + protected function add_tag(int $ticket_id, string $value): void + { + $tag_id = $this->resolve_tag_id($value); + if ($tag_id === null) { + $this->log_debug(sprintf('add_tag: tag "%s" not found', $value)); + + return; + } + $this->ticket_service->add_tags($ticket_id, [$tag_id]); + } + + protected function remove_tag(int $ticket_id, string $value): void + { + $tag_id = $this->resolve_tag_id($value); + if ($tag_id === null) { + return; + } + $this->ticket_service->remove_tags($ticket_id, [$tag_id]); + } + + /** + * Resolve a tag value (slug or numeric id) to an integer id. + * Overridable for tests via subclass. + */ + protected function resolve_tag_id(string $value): ?int + { + if ($value === '') { + return null; + } + // Try slug first. + $tag = Tag::find_by_slug($value); + if ($tag && isset($tag->id)) { + return (int) $tag->id; + } + // Fall back to numeric id. + if (ctype_digit($value)) { + $by_id = Tag::find((int) $value); + if ($by_id && isset($by_id->id)) { + return (int) $by_id->id; + } + } + + return null; + } + + protected function add_note(int $ticket_id, string $body): void + { + $body = trim($body); + if ($body === '') { + return; + } + Reply::create([ + 'ticket_id' => $ticket_id, + 'author_id' => null, + 'body' => $body, + 'is_internal_note' => 1, + 'is_pinned' => 0, + 'type' => 'note', + 'metadata' => wp_json_encode(['system_note' => true, 'source' => 'workflow']), + ]); + } + + /** + * Insert an agent-visible reply built from a template. {{field}} + * placeholders are interpolated against the ticket via + * WorkflowEngine::interpolateVariables. Unknown variables stay + * as literal {{...}} so the reader can see the gap. + */ + protected function insert_canned_reply(object $ticket, string $template): void + { + $template = trim($template); + if ($template === '') { + return; + } + $body = WorkflowEngine::interpolateVariables($template, $this->ticket_to_array($ticket)); + Reply::create([ + 'ticket_id' => (int) $ticket->id, + 'author_id' => null, + 'body' => $body, + 'is_internal_note' => 0, + 'is_pinned' => 0, + 'type' => 'reply', + 'metadata' => wp_json_encode(['system_reply' => true, 'source' => 'workflow']), + ]); + } + + /** + * Flatten a ticket row (object) into a string map for the template + * interpolator. Non-scalar fields are dropped. + * + * @return array + */ + protected function ticket_to_array(object $ticket): array + { + $out = []; + foreach (get_object_vars($ticket) as $key => $val) { + if ($val === null || $val === '' || is_array($val) || is_object($val)) { + continue; + } + $out[$key] = (string) $val; + } + + return $out; + } + + protected function log_action_failure(object $ticket, array $action, \Throwable $e): void + { + $this->log_debug(sprintf( + 'action failed: type=%s ticket=%d error=%s', + $action['type'] ?? '?', + (int) ($ticket->id ?? 0), + $e->getMessage() + )); + } + + protected function log_debug(string $message): void + { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log('[Escalated\\WorkflowExecutor] '.$message); + } + } +} diff --git a/tests/Test_Workflow_Executor_Service.php b/tests/Test_Workflow_Executor_Service.php new file mode 100644 index 0000000..81322a0 --- /dev/null +++ b/tests/Test_Workflow_Executor_Service.php @@ -0,0 +1,275 @@ +executor = new WorkflowExecutorService; + $this->ticket_service = new TicketService; + $this->user_id = $this->factory->user->create(['role' => 'subscriber']); + $this->agent_id = $this->factory->user->create(['role' => 'escalated_agent']); + } + + 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)); + } + + public function test_execute_change_priority_updates_ticket(): void + { + $ticket = $this->make_ticket(); + + $this->executor->execute( + $ticket, + wp_json_encode([['type' => 'change_priority', 'value' => 'high']]) + ); + + $fresh = Ticket::find($ticket->id); + $this->assertEquals('high', $fresh->priority); + } + + public function test_execute_change_status_updates_ticket(): void + { + $ticket = $this->make_ticket(); + + $this->executor->execute( + $ticket, + wp_json_encode([['type' => 'change_status', 'value' => 'resolved']]) + ); + + $fresh = Ticket::find($ticket->id); + $this->assertEquals('resolved', $fresh->status); + } + + public function test_execute_assign_agent_sets_assignee(): void + { + $ticket = $this->make_ticket(); + + $this->executor->execute( + $ticket, + wp_json_encode([['type' => 'assign_agent', 'value' => (string) $this->agent_id]]) + ); + + $fresh = Ticket::find($ticket->id); + $this->assertEquals($this->agent_id, (int) $fresh->assigned_to); + } + + public function test_execute_assign_agent_blank_value_is_noop(): void + { + $ticket = $this->make_ticket(); + + $this->executor->execute( + $ticket, + wp_json_encode([['type' => 'assign_agent', 'value' => '0']]) + ); + + $fresh = Ticket::find($ticket->id); + $this->assertEmpty($fresh->assigned_to); + } + + public function test_execute_add_note_creates_internal_reply(): void + { + $ticket = $this->make_ticket(); + + $this->executor->execute( + $ticket, + wp_json_encode([['type' => 'add_note', 'value' => 'Triaged by workflow']]) + ); + + global $wpdb; + $table = Reply::table(); + $row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY id DESC LIMIT 1", $ticket->id) + ); + $this->assertNotNull($row); + $this->assertEquals('Triaged by workflow', $row->body); + $this->assertEquals(1, (int) $row->is_internal_note); + $this->assertEquals('note', $row->type); + } + + public function test_execute_add_note_blank_value_skipped(): void + { + $ticket = $this->make_ticket(); + + $this->executor->execute( + $ticket, + wp_json_encode([['type' => 'add_note', 'value' => ' ']]) + ); + + global $wpdb; + $table = Reply::table(); + $count = (int) $wpdb->get_var( + $wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE ticket_id = %d", $ticket->id) + ); + $this->assertEquals(0, $count); + } + + public function test_execute_insert_canned_reply_interpolates_variables(): void + { + $ticket = $this->make_ticket(['subject' => 'Login issue']); + + $this->executor->execute( + $ticket, + wp_json_encode([[ + 'type' => 'insert_canned_reply', + 'value' => 'Re: {{subject}} (ref {{reference}})', + ]]) + ); + + global $wpdb; + $table = Reply::table(); + $row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY id DESC LIMIT 1", $ticket->id) + ); + $this->assertNotNull($row); + $this->assertStringContainsString('Re: Login issue (ref', $row->body); + $this->assertEquals(0, (int) $row->is_internal_note); + $this->assertEquals('reply', $row->type); + } + + public function test_execute_insert_canned_reply_unknown_variable_left_literal(): void + { + $ticket = $this->make_ticket(); + + $this->executor->execute( + $ticket, + wp_json_encode([[ + 'type' => 'insert_canned_reply', + 'value' => 'Hi {{not_a_field}}', + ]]) + ); + + global $wpdb; + $table = Reply::table(); + $row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY id DESC LIMIT 1", $ticket->id) + ); + $this->assertEquals('Hi {{not_a_field}}', $row->body); + } + + public function test_execute_malformed_json_returns_empty_actions(): void + { + $ticket = $this->make_ticket(); + + $result = $this->executor->execute($ticket, 'not json'); + + $this->assertSame([], $result); + } + + public function test_execute_empty_string_returns_empty_actions(): void + { + $ticket = $this->make_ticket(); + + $result = $this->executor->execute($ticket, ''); + + $this->assertSame([], $result); + } + + public function test_execute_null_returns_empty_actions(): void + { + $ticket = $this->make_ticket(); + + $result = $this->executor->execute($ticket, null); + + $this->assertSame([], $result); + } + + public function test_execute_unknown_action_type_skipped(): void + { + $ticket = $this->make_ticket(); + + // Should not throw; other actions in the same list should still run. + $result = $this->executor->execute( + $ticket, + wp_json_encode([ + ['type' => 'future_action', 'value' => 'x'], + ['type' => 'change_priority', 'value' => 'urgent'], + ]) + ); + + $this->assertCount(2, $result); + $fresh = Ticket::find($ticket->id); + $this->assertEquals('urgent', $fresh->priority); + } + + public function test_execute_add_tag_by_slug_attaches_tag(): void + { + $ticket = $this->make_ticket(); + global $wpdb; + $tags_table = Tag::table(); + $wpdb->insert($tags_table, [ + 'name' => 'urgent', + 'slug' => 'urgent', + 'color' => '#ff0000', + 'created_at' => current_time('mysql'), + 'updated_at' => current_time('mysql'), + ]); + $tag_id = (int) $wpdb->insert_id; + + $this->executor->execute( + $ticket, + wp_json_encode([['type' => 'add_tag', 'value' => 'urgent']]) + ); + + $pivot = Escalated\Escalated::table('ticket_tag'); + $attached = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$pivot} WHERE ticket_id = %d AND tag_id = %d", + $ticket->id, + $tag_id + ) + ); + $this->assertEquals(1, $attached); + } + + public function test_execute_returns_parsed_action_list(): void + { + $ticket = $this->make_ticket(); + + $result = $this->executor->execute( + $ticket, + wp_json_encode([ + ['type' => 'change_priority', 'value' => 'high'], + ['type' => 'add_note', 'value' => 'go'], + ]) + ); + + $this->assertCount(2, $result); + $this->assertEquals('change_priority', $result[0]['type']); + $this->assertEquals('add_note', $result[1]['type']); + } +}