Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions includes/Services/WorkflowListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Escalated\Services;

use Escalated\Models\Ticket;

/**
* Bridges WordPress `escalated_*` actions into WorkflowRunnerService.
*
* Final piece of the workflow stack for WordPress. Each hook handler
* maps the WP action to a canonical workflow trigger name (matching
* the 12-event set in WorkflowEngine::TRIGGER_EVENTS) and delegates
* to the runner.
*
* Register once during plugin boot by calling ->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()
));
}
}
}
}
1 change: 1 addition & 0 deletions includes/class-escalated.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
167 changes: 167 additions & 0 deletions tests/Test_Workflow_Listener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

/**
* Tests for WorkflowListener — confirms the registered WP hooks fire
* the runner with the correct trigger event name.
*
* Mirrors the NestJS workflow.listener.ts unit tests and the event
* mapping confirmed by the Spring WorkflowListenerTest.
*/

use Escalated\Escalated;
use Escalated\Models\Ticket;
use Escalated\Models\Workflow;
use Escalated\Services\AssignmentService;
use Escalated\Services\TicketService;
use Escalated\Services\WorkflowListener;

class Test_Workflow_Listener extends WP_UnitTestCase
{
private WorkflowListener $listener;

private TicketService $ticket_service;

private int $user_id;

private int $agent_id;

public function set_up(): void
{
parent::set_up();
\Escalated\Activator::activate();

$this->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));
}
}