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
269 changes: 269 additions & 0 deletions includes/Services/WorkflowExecutorService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
<?php

namespace Escalated\Services;

use Escalated\Models\Reply;
use Escalated\Models\Tag;

/**
* Performs the side-effects dictated by a matched Workflow.
*
* Distinct from WorkflowEngine (which only evaluates conditions).
* Parses the JSON action array stored on `workflow.actions` and
* dispatches each entry to the relevant service.
*
* Action catalog: change_priority, change_status, assign_agent,
* set_department, add_tag, remove_tag, add_note, insert_canned_reply.
* Mirrors the NestJS reference impl in
* escalated-nestjs/src/services/workflow-executor.service.ts.
*
* One failing action never halts the others — matches NestJS. Unknown
* action types warn-log (via error_log when WP_DEBUG) and skip.
*/
class WorkflowExecutorService
{
protected TicketService $ticket_service;

protected AssignmentService $assignment_service;

public function __construct(
?TicketService $ticket_service = null,
?AssignmentService $assignment_service = null
) {
$this->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<string,string>
*/
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);
}
}
}
Loading
Loading