diff --git a/includes/Services/WorkflowListener.php b/includes/Services/WorkflowListener.php new file mode 100644 index 0000000..3376c1e --- /dev/null +++ b/includes/Services/WorkflowListener.php @@ -0,0 +1,110 @@ +register(). + * + * Mirrors the NestJS workflow.listener.ts and the Laravel + * ProcessWorkflows listener. + */ +class WorkflowListener +{ + protected WorkflowRunnerService $runner; + + public function __construct(?WorkflowRunnerService $runner = null) + { + $this->runner = $runner ?? new WorkflowRunnerService; + } + + public function register(): void + { + // Priority 50 — after the normal product-side handlers (10-20) + // but before webhooks / mail (100+). Workflows should observe + // the already-persisted state and fire their own side-effects + // into the same hook chain. + add_action('escalated_ticket_created', [$this, 'on_ticket_created'], 50, 1); + add_action('escalated_ticket_updated', [$this, 'on_ticket_updated'], 50, 1); + add_action('escalated_ticket_status_changed', [$this, 'on_ticket_status_changed'], 50, 4); + add_action('escalated_ticket_assigned', [$this, 'on_ticket_assigned'], 50, 4); + add_action('escalated_ticket_reopened', [$this, 'on_ticket_reopened'], 50, 2); + add_action('escalated_reply_created', [$this, 'on_reply_created'], 50, 2); + add_action('escalated_tag_added', [$this, 'on_tag_changed'], 50, 2); + add_action('escalated_tag_removed', [$this, 'on_tag_changed'], 50, 2); + add_action('escalated_department_changed', [$this, 'on_department_changed'], 50, 4); + } + + public function on_ticket_created($ticket): void + { + $this->run_if_ticket('ticket.created', $ticket); + } + + public function on_ticket_updated($ticket): void + { + $this->run_if_ticket('ticket.updated', $ticket); + } + + public function on_ticket_status_changed($ticket, $old_status = null, $new_status = null, $causer_id = null): void + { + $this->run_if_ticket('ticket.status_changed', $ticket); + } + + public function on_ticket_assigned($ticket, $agent_id = null, $old_agent_id = null, $causer_id = null): void + { + $this->run_if_ticket('ticket.assigned', $ticket); + } + + public function on_ticket_reopened($ticket, $causer_id = null): void + { + $this->run_if_ticket('ticket.reopened', $ticket); + } + + public function on_reply_created($reply, $ticket = null): void + { + if ($ticket === null && is_object($reply) && ! empty($reply->ticket_id)) { + $ticket = Ticket::find((int) $reply->ticket_id); + } + $this->run_if_ticket('reply.created', $ticket); + } + + public function on_tag_changed($ticket_id, $tag_id = null): void + { + if (! is_numeric($ticket_id)) { + return; + } + $ticket = Ticket::find((int) $ticket_id); + $this->run_if_ticket('ticket.tagged', $ticket); + } + + public function on_department_changed($ticket, $old_department_id = null, $new_department_id = null, $causer_id = null): void + { + $this->run_if_ticket('ticket.department_changed', $ticket); + } + + protected function run_if_ticket(string $trigger, $ticket): void + { + if (! is_object($ticket) || empty($ticket->id)) { + return; + } + try { + $this->runner->run_for_event($trigger, $ticket); + } catch (\Throwable $e) { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log(sprintf( + '[Escalated\\WorkflowListener] %s handler failed: %s', + $trigger, + $e->getMessage() + )); + } + } + } +} diff --git a/includes/class-escalated.php b/includes/class-escalated.php index 0658589..23a5d37 100644 --- a/includes/class-escalated.php +++ b/includes/class-escalated.php @@ -37,6 +37,7 @@ public function boot(): void (new Cron\Auto_Close)->register(); (new Cron\Activity_Purge)->register(); (new Services\BroadcastService)->register(); + (new Services\WorkflowListener)->register(); (new Cron\Snooze_Check)->register(); (new Cron\Chat_Cleanup)->register(); diff --git a/tests/Test_Workflow_Listener.php b/tests/Test_Workflow_Listener.php new file mode 100644 index 0000000..f7d4b10 --- /dev/null +++ b/tests/Test_Workflow_Listener.php @@ -0,0 +1,167 @@ +listener = new WorkflowListener; + $this->listener->register(); + + $this->ticket_service = new TicketService; + $this->user_id = $this->factory->user->create(['role' => 'subscriber']); + $this->agent_id = $this->factory->user->create(['role' => 'escalated_agent']); + } + + public function tear_down(): void + { + remove_action('escalated_ticket_created', [$this->listener, 'on_ticket_created'], 50); + remove_action('escalated_ticket_updated', [$this->listener, 'on_ticket_updated'], 50); + remove_action('escalated_ticket_status_changed', [$this->listener, 'on_ticket_status_changed'], 50); + remove_action('escalated_ticket_assigned', [$this->listener, 'on_ticket_assigned'], 50); + remove_action('escalated_ticket_reopened', [$this->listener, 'on_ticket_reopened'], 50); + remove_action('escalated_reply_created', [$this->listener, 'on_reply_created'], 50); + remove_action('escalated_tag_added', [$this->listener, 'on_tag_changed'], 50); + remove_action('escalated_tag_removed', [$this->listener, 'on_tag_changed'], 50); + remove_action('escalated_department_changed', [$this->listener, 'on_department_changed'], 50); + parent::tear_down(); + } + + private function create_workflow_for(string $trigger, array $actions): int + { + global $wpdb; + $wpdb->insert(Workflow::table(), [ + 'name' => 'Test wf for '.$trigger, + 'trigger_event' => $trigger, + 'conditions' => null, + 'actions' => wp_json_encode($actions), + 'is_active' => 1, + 'position' => 0, + 'stop_on_match' => 0, + 'created_at' => current_time('mysql'), + 'updated_at' => current_time('mysql'), + ]); + + return (int) $wpdb->insert_id; + } + + private function make_ticket(array $overrides = []): object + { + return $this->ticket_service->create( + $this->user_id, + array_merge(['subject' => 'T', 'description' => 'b', 'channel' => 'web'], $overrides) + ); + } + + private function count_logs_for(int $wf_id): int + { + global $wpdb; + $table = Escalated::table('workflow_logs'); + + return (int) $wpdb->get_var( + $wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE workflow_id = %d", $wf_id) + ); + } + + public function test_ticket_created_fires_workflow(): void + { + $wf_id = $this->create_workflow_for( + 'ticket.created', + [['type' => 'add_note', 'value' => 'auto-triaged']] + ); + + $this->make_ticket(); // TicketService::create fires escalated_ticket_created + + $this->assertEquals(1, $this->count_logs_for($wf_id)); + } + + public function test_status_changed_fires_status_changed_workflow(): void + { + $wf_id = $this->create_workflow_for( + 'ticket.status_changed', + [['type' => 'add_note', 'value' => 'status autolog']] + ); + $ticket = $this->make_ticket(); + + $this->ticket_service->change_status($ticket->id, 'resolved'); + + $this->assertGreaterThanOrEqual(1, $this->count_logs_for($wf_id)); + } + + public function test_assigned_fires_assigned_workflow(): void + { + $wf_id = $this->create_workflow_for( + 'ticket.assigned', + [['type' => 'add_note', 'value' => 'assigned autolog']] + ); + $ticket = $this->make_ticket(); + + $assign = new AssignmentService; + $assign->assign($ticket->id, $this->agent_id); + + $this->assertGreaterThanOrEqual(1, $this->count_logs_for($wf_id)); + } + + public function test_reply_created_fires_reply_workflow(): void + { + $wf_id = $this->create_workflow_for( + 'reply.created', + [['type' => 'add_note', 'value' => 'reply autolog']] + ); + $ticket = $this->make_ticket(); + + $this->ticket_service->reply($ticket->id, $this->agent_id, 'customer answer'); + + $this->assertGreaterThanOrEqual(1, $this->count_logs_for($wf_id)); + } + + public function test_non_matching_trigger_does_not_fire(): void + { + // Workflow is for reply.created; we only create a ticket. + $wf_id = $this->create_workflow_for( + 'reply.created', + [['type' => 'add_note', 'value' => 'should not fire']] + ); + + $this->make_ticket(); + + $this->assertEquals(0, $this->count_logs_for($wf_id)); + } + + public function test_missing_ticket_in_hook_is_tolerated(): void + { + $wf_id = $this->create_workflow_for('ticket.created', []); + + // Fire the hook with bogus data — listener should swallow it. + do_action('escalated_ticket_created', null); + do_action('escalated_ticket_created', 'not an object'); + do_action('escalated_ticket_created', (object) ['id' => 0]); + + $this->assertEquals(0, $this->count_logs_for($wf_id)); + } +}