Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- Live chat transfer (escalated-laravel `escalated.admin.chat.transfer`): `POST /wp-json/escalated/v1/chat/{session_id}/transfer`, with an API token like the other agent chat endpoints. The body is `{agent_id, department_id}` (either or both); the shared `ChatActionBar`'s `{type: "agent"|"department", id}` is accepted too. With an agent, the session and ticket are reassigned to them. With only a department, the ticket moves there and the chat goes back to the queue (unassigned, waiting), then chat routing runs again for the new department. A missing or non-integer target, an unknown department, an ended chat, or an agent who is not staff or cannot see the ticket is a 422; an unknown session is a 404. New `ChatSessionService::transfer()` and `escalated_chat_transferred` action (`$session, $from_agent_id, $to_agent_id, $to_department_id`).
- `chat.transferred` runs workflows (new trigger, "Chat transferred") and is sent to webhooks subscribed to it; the webhook form offers it. Its payload is the ticket plus `chat {session_id, ticket_id, status, agent_id, department_id, from_agent_id, to_agent_id, to_department_id}`, as escalated-laravel's. Existing webhooks are not subscribed to it automatically.

## [1.6.4] - 2026-10-22

### Upgrading
Expand Down
2 changes: 1 addition & 1 deletion docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The `escalated_webhook_args` filter can change the request arguments. `escalated

The canonical names from the shared event vocabulary:

`ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.resolved`, `ticket.closed`, `ticket.reopened`, `ticket.assigned`, `ticket.unassigned`, `ticket.escalated`, `ticket.priority_changed`, `ticket.department_changed`, `reply.created`, `note.created`, `sla.breached`, `sla.warning`, `ticket.tag_added`, `ticket.tag_removed`.
`ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.resolved`, `ticket.closed`, `ticket.reopened`, `ticket.assigned`, `ticket.unassigned`, `ticket.escalated`, `ticket.priority_changed`, `ticket.department_changed`, `reply.created`, `note.created`, `sla.breached`, `sla.warning`, `ticket.tag_added`, `ticket.tag_removed`, `chat.transferred`.

A webhook saved with an older name (`ticket.tagged`, `internal_note.added`, ...) receives the event under its current name.

Expand Down
75 changes: 75 additions & 0 deletions includes/Api/class-chat-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,27 @@ public function register_routes(): void
]
);

// Transfer a chat to another agent and/or department
// (escalated-laravel escalated.admin.chat.transfer).
register_rest_route(
$this->namespace,
'/'.$this->rest_base.'/(?P<session_id>\d+)/transfer',
[
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'transfer_session'],
'permission_callback' => [$this, 'token_permissions_check'],
'args' => [
'session_id' => [
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
],
],
],
]
);

// Get a specific session.
register_rest_route(
$this->namespace,
Expand Down Expand Up @@ -212,6 +233,60 @@ public function end_session(WP_REST_Request $request)
}
}

/**
* POST /chat/{session_id}/transfer. The body is {agent_id, department_id}
* (either or both), as escalated-laravel's ChatController::transfer; the
* shared ChatActionBar's {type: "agent"|"department", id} is read too.
*/
public function transfer_session(WP_REST_Request $request)
{
$session_id = (int) $request->get_param('session_id');

if (! \Escalated\Models\ChatSession::find($session_id)) {
return $this->error('escalated_not_found', __('Chat session not found.', 'escalated'), 404);
}

$agent_id = $request->get_param('agent_id');
$department_id = $request->get_param('department_id');
$type = $request->get_param('type');
if ($type === 'agent' && self::blank($agent_id)) {
$agent_id = $request->get_param('id');
} elseif ($type === 'department' && self::blank($department_id)) {
$department_id = $request->get_param('id');
}

foreach (['agent_id' => $agent_id, 'department_id' => $department_id] as $field => $value) {
if (! self::blank($value) && (is_bool($value) || filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false)) {
/* translators: %s: field name */
return $this->error('escalated_validation', sprintf(__('The %s must be an integer.', 'escalated'), $field), 422);
}
}
$agent_id = self::blank($agent_id) ? null : (int) $agent_id;
$department_id = self::blank($department_id) ? null : (int) $department_id;

if ($agent_id === null && $department_id === null) {
return $this->error('escalated_validation', __('Provide an agent_id or department_id.', 'escalated'), 422);
}

try {
$session = (new ChatSessionService)->transfer($session_id, $agent_id, $department_id);
} catch (\InvalidArgumentException $e) {
return $this->error('escalated_chat_transfer_refused', $e->getMessage(), 422);
} catch (\Throwable $e) {
return $this->error('escalated_chat_error', $e->getMessage(), 500);
}

return $this->success([
'message' => __('Chat transferred.', 'escalated'),
'session' => $session,
]);
}

private static function blank($value): bool
{
return $value === null || (is_string($value) && trim($value) === '');
}

public function get_session(WP_REST_Request $request)
{
$session_id = (int) $request->get_param('session_id');
Expand Down
80 changes: 80 additions & 0 deletions includes/Services/ChatSessionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,86 @@ public function accept(int $session_id, int $agent_id): object
return $session;
}

/**
* Hand a chat to another agent and/or department, as escalated-laravel's
* ChatSessionService::transferChat. With an agent, the session and its
* ticket are reassigned to that agent. With a department, the ticket
* moves there; without an agent too, the chat goes back to the queue
* (unassigned, waiting) and routing picks an agent for its new
* department. Fires escalated_chat_transferred (chat.transferred) with
* the previous agent, the new agent and the new department.
*
* @throws \InvalidArgumentException when neither target is given, the chat
* has ended, the department does not
* exist, or the agent cannot take it
*/
public function transfer(int $session_id, ?int $to_agent_id = null, ?int $to_department_id = null): object
{
$session = ChatSession::find($session_id);

if (! $session) {
throw new \InvalidArgumentException('Chat session not found.');
}

if ($session->status === 'ended') {
throw new \InvalidArgumentException('Chat session has already ended.');
}

if (! $to_agent_id && ! $to_department_id) {
throw new \InvalidArgumentException('Provide an agent_id or department_id.');
}

if ($to_department_id && ! \Escalated\Models\Department::find($to_department_id)) {
throw new \InvalidArgumentException('The selected department_id is invalid.');
}

$ticket_id = (int) $session->ticket_id;
$from_agent_id = $session->agent_id ? (int) $session->agent_id : null;

return TicketAccess::without_restriction(function () use ($session_id, $ticket_id, $from_agent_id, $to_agent_id, $to_department_id) {
// Checked against the ticket as it will be (in its new department).
$prospective = (array) Ticket::find($ticket_id);
if ($to_department_id) {
$prospective['department_id'] = $to_department_id;
}
if ($to_agent_id && (! TicketAccess::is_staff($to_agent_id) || ! TicketAccess::allows($prospective, $to_agent_id))) {
throw new \InvalidArgumentException('Agent cannot access this ticket.');
}

$ticket_updates = ['assigned_to' => $to_agent_id ?: null];
if ($to_department_id) {
$ticket_updates['department_id'] = $to_department_id;
}
Ticket::update($ticket_id, $ticket_updates);

$session_updates = [];
if ($to_department_id) {
$session_updates['department_id'] = $to_department_id;
}
if ($to_agent_id) {
$session_updates['agent_id'] = $to_agent_id;
} else {
$session_updates['agent_id'] = null;
$session_updates['status'] = 'waiting';
$session_updates['accepted_at'] = null;
}
ChatSession::update($session_id, $session_updates);

$session = ChatSession::find($session_id);

do_action('escalated_chat_transferred', $session, $from_agent_id, $to_agent_id, $to_department_id);

if (! $to_agent_id) {
$route = $this->routing->resolve($to_department_id);
if (! empty($route['agent_id']) && TicketAccess::can_receive($ticket_id, (int) $route['agent_id'])) {
$session = $this->accept($session_id, (int) $route['agent_id']);
}
}

return $session;
});
}

/**
* Send a message within a chat session. Stored as a reply on the ticket.
*/
Expand Down
5 changes: 3 additions & 2 deletions includes/Services/EventVocabulary.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ final class EventVocabulary
'sla.warning',
'ticket.tag_added',
'ticket.tag_removed',
'chat.transferred',
];

/**
* The events WorkflowListener runs workflows on. Live chat has no
* transfer, so chat.transferred is not fired. reply.agent_reply is a
* The events WorkflowListener runs workflows on. reply.agent_reply is a
* WordPress extension: reply.created limited to staff replies.
*/
public const WORKFLOW_TRIGGERS = [
Expand All @@ -77,6 +77,7 @@ final class EventVocabulary
'sla.breached',
'chat.started',
'chat.assigned',
'chat.transferred',
'chat.message',
'chat.ended',
];
Expand Down
24 changes: 24 additions & 0 deletions includes/Services/NotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function register(): void
add_action('escalated_internal_note_added', [$this, 'on_note_created'], 100, 2);
add_action('escalated_tag_added', [$this, 'on_tag_added'], 100, 2);
add_action('escalated_tag_removed', [$this, 'on_tag_removed'], 100, 2);
add_action('escalated_chat_transferred', [$this, 'on_chat_transferred'], 100, 4);
}

public function on_ticket_resolved($ticket, $causer_id = null): void
Expand Down Expand Up @@ -87,6 +88,29 @@ public function on_note_created($note, $ticket = null): void
]);
}

/**
* chat.transferred, with escalated-laravel's DispatchWebhook chat fields.
* The visitor's email and chat token stay out.
*/
public function on_chat_transferred($session, $from_agent_id = null, $to_agent_id = null, $to_department_id = null): void
{
if (! is_object($session) || empty($session->ticket_id)) {
return;
}
$this->dispatch_ticket_event('chat.transferred', Ticket::find((int) $session->ticket_id), [
'chat' => [
'session_id' => (int) $session->id,
'ticket_id' => (int) $session->ticket_id,
'status' => $session->status ?? null,
'agent_id' => self::nullable_int($session->agent_id ?? null),
'department_id' => self::nullable_int($session->department_id ?? null),
'from_agent_id' => self::nullable_int($from_agent_id),
'to_agent_id' => self::nullable_int($to_agent_id),
'to_department_id' => self::nullable_int($to_department_id),
],
]);
}

public function on_tag_added($ticket_id, $tag_id = null): void
{
$this->dispatch_tag_event('ticket.tag_added', $ticket_id, $tag_id);
Expand Down
1 change: 1 addition & 0 deletions includes/Services/WorkflowAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ public static function trigger_label(string $event): string
'sla.breached' => __('SLA breached', 'escalated'),
'chat.started' => __('Chat started', 'escalated'),
'chat.assigned' => __('Chat assigned', 'escalated'),
'chat.transferred' => __('Chat transferred', 'escalated'),
'chat.message' => __('Chat message', 'escalated'),
'chat.ended' => __('Chat ended', 'escalated'),
];
Expand Down
6 changes: 6 additions & 0 deletions includes/Services/WorkflowListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public function register(): void
add_action('escalated_sla_breached', [$this, 'on_sla_breached'], 50, 2);
add_action('escalated_chat_started', [$this, 'on_chat_started'], 50, 1);
add_action('escalated_chat_accepted', [$this, 'on_chat_assigned'], 50, 2);
add_action('escalated_chat_transferred', [$this, 'on_chat_transferred'], 50, 4);
add_action('escalated_chat_message', [$this, 'on_chat_message'], 50, 2);
add_action('escalated_chat_ended', [$this, 'on_chat_ended'], 50, 2);
}
Expand Down Expand Up @@ -123,6 +124,11 @@ public function on_chat_assigned($session, $agent_id = null): void
$this->run_for_chat('chat.assigned', $session);
}

public function on_chat_transferred($session, $from_agent_id = null, $to_agent_id = null, $to_department_id = null): void
{
$this->run_for_chat('chat.transferred', $session);
}

public function on_chat_message($session, $reply = null): void
{
$this->run_for_chat('chat.message', $session);
Expand Down
Loading
Loading