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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Live chat transfer (escalated-laravel `ChatController::transfer`): `POST /escalated/api/agent/chat/{sessionId}/transfer`, behind the agent check like the rest of the agent chat API, and, for the shared `Agent/TicketShow` transfer menu, `POST /escalated/agent/chat/{session}/transfer` from the agent's browser session (CSRF-checked, like End Chat), registered as `escalated.admin.chat.transfer` (Laravel's name). 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 active staff or cannot be assigned the ticket is a 422; an unknown session is a 404. New `ChatSessionService.transfer(...)`.
- `chat.transferred` is sent to outbound webhooks, runs workflows and is now in `EventNames.EMITTED`, so the webhook form and workflow builder offer it. The webhook data adds `from_agent_id`, `to_agent_id` and `to_department_id` to the chat fields (no widget token). `ChatEvent` has a `TRANSFERRED` type and `getTransfer()`.
- End Chat on the agent ticket page works from the Inertia UI: `POST /escalated/agent/chat/{session}/end`
(`escalated.admin.chat.end`, the name the shared TicketShow asks for, taking the chat session id)
ends the session from the agent's signed-in browser session with the page's CSRF token. It reads
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,79 @@ public ResponseEntity<ChatSession> end(@PathVariable Long sessionId) {
return ResponseEntity.ok(chatSessionService.end(sessionId));
}

/**
* Transfer a chat to another agent and/or department (escalated-laravel
* {@code escalated.admin.chat.transfer}). The body is
* {@code {agent_id, department_id}} (either or both); the shared
* ChatActionBar's {@code {type: "agent"|"department", id}} is read too.
*/
@PostMapping("/{sessionId}/transfer")
public ResponseEntity<Map<String, Object>> transfer(@PathVariable Long sessionId,
@RequestBody(required = false) Map<String, Object> body) {
return transfer(chatSessionService, sessionId, body);
}

/**
* The transfer itself, shared with the agent UI route that TicketShow posts to from the
* browser session. An unknown session throws {@code EntityNotFoundException} (404).
*/
public static ResponseEntity<Map<String, Object>> transfer(ChatSessionService chatSessionService,
Long sessionId, Map<String, Object> body) {
chatSessionService.findById(sessionId);
Map<String, Object> in = body == null ? Map.of() : body;
Object agent = in.get("agent_id");
Object department = in.get("department_id");
Object type = in.get("type");
if ("agent".equals(type) && blank(agent)) {
agent = in.get("id");
} else if ("department".equals(type) && blank(department)) {
department = in.get("id");
}

Long agentId;
Long departmentId;
try {
agentId = id(agent, "agent_id");
departmentId = id(department, "department_id");
} catch (IllegalArgumentException invalid) {
return unprocessable(invalid.getMessage());
}
if (agentId == null && departmentId == null) {
return unprocessable("Provide an agent_id or department_id.");
}

try {
ChatSession session = chatSessionService.transfer(sessionId, agentId, departmentId);
Map<String, Object> response = new java.util.LinkedHashMap<>();
response.put("message", "Chat transferred.");
response.put("session", session);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException refused) {
return unprocessable(refused.getMessage());
}
}

private static boolean blank(Object value) {
return value == null || (value instanceof String text && text.isBlank());
}

private static Long id(Object value, String field) {
if (blank(value)) {
return null;
}
if (value instanceof Number number && number.doubleValue() == number.longValue() && number.longValue() > 0) {
return number.longValue();
}
if (value instanceof String text && text.trim().matches("[1-9][0-9]{0,17}")) {
return Long.valueOf(text.trim());
}
throw new IllegalArgumentException("The " + field + " must be an integer.");
}

private static ResponseEntity<Map<String, Object>> unprocessable(String message) {
return ResponseEntity.status(422).body(Map.of("message", message));
}

@GetMapping("/{sessionId}")
public ResponseEntity<ChatSession> show(@PathVariable Long sessionId) {
return ResponseEntity.ok(chatSessionService.findById(sessionId));
Expand Down
21 changes: 19 additions & 2 deletions src/main/java/dev/escalated/events/ChatEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@

/**
* A live-chat lifecycle event, announced to webhooks and workflows as
* {@code chat.started}, {@code chat.assigned}, {@code chat.message} or
* {@code chat.ended}. Carries ids only: never the visitor's widget token.
* {@code chat.started}, {@code chat.assigned}, {@code chat.transferred},
* {@code chat.message} or {@code chat.ended}. Carries ids only: never the
* visitor's widget token.
*/
public class ChatEvent extends ApplicationEvent {

public enum Type {
STARTED(EventNames.CHAT_STARTED),
ASSIGNED(EventNames.CHAT_ASSIGNED),
TRANSFERRED(EventNames.CHAT_TRANSFERRED),
MESSAGE(EventNames.CHAT_MESSAGE),
ENDED(EventNames.CHAT_ENDED);

Expand All @@ -31,13 +33,28 @@ public String eventName() {
private final Long sessionId;
private final Long ticketId;
private final Long agentId;
private final Transfer transfer;

/** Who a chat was transferred from and to (escalated-laravel's ChatTransferred). */
public record Transfer(Long fromAgentId, Long toAgentId, Long toDepartmentId) {
}

public ChatEvent(Object source, Type type, ChatSession session) {
this(source, type, session, null);
}

public ChatEvent(Object source, Type type, ChatSession session, Transfer transfer) {
super(source);
this.type = type;
this.sessionId = session.getId();
this.ticketId = session.getTicketId();
this.agentId = session.getAgentId();
this.transfer = transfer;
}

/** The transfer details of a {@code chat.transferred} event; null otherwise. */
public Transfer getTransfer() {
return transfer;
}

public Type getType() {
Expand Down
10 changes: 2 additions & 8 deletions src/main/java/dev/escalated/events/EventNames.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,8 @@ public final class EventNames {
TICKET_DEPARTMENT_CHANGED, TICKET_TAG_ADDED, TICKET_TAG_REMOVED, REPLY_CREATED, NOTE_CREATED,
SLA_WARNING, SLA_BREACHED, CHAT_STARTED, CHAT_ASSIGNED, CHAT_TRANSFERRED, CHAT_MESSAGE, CHAT_ENDED);

/**
* The canonical names this package emits, and so offers as workflow
* triggers. {@code chat.transferred} is canonical but this port has no
* chat transfer yet.
*/
public static final List<String> EMITTED = CANONICAL.stream()
.filter(name -> !CHAT_TRANSFERRED.equals(name))
.toList();
/** The canonical names this package emits, and so offers as workflow triggers. */
public static final List<String> EMITTED = CANONICAL;

/** Legacy name to canonical name. Read, never emitted. */
public static final Map<String, String> LEGACY;
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/dev/escalated/events/WebhookEventListener.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ public void onChatEvent(ChatEvent event) {
if (event.getAgentId() != null) {
data.put("agent_id", event.getAgentId());
}
if (event.getTransfer() != null) {
data.put("from_agent_id", event.getTransfer().fromAgentId());
data.put("to_agent_id", event.getTransfer().toAgentId());
data.put("to_department_id", event.getTransfer().toDepartmentId());
}
webhookService.dispatchEvent(event.getType().eventName(), event.getTicketId(), data);
}

Expand Down
100 changes: 100 additions & 0 deletions src/main/java/dev/escalated/services/ChatSessionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ public ChatSessionService(ChatSessionRepository chatSessionRepository,
this.messagingTemplate = messagingTemplate;
}

@org.springframework.beans.factory.annotation.Autowired
private dev.escalated.repositories.AgentProfileRepository agentProfiles;

@org.springframework.beans.factory.annotation.Autowired
private dev.escalated.repositories.DepartmentRepository departments;

@org.springframework.beans.factory.annotation.Autowired
private dev.escalated.repositories.TicketRepository tickets;

private void announce(ChatEvent.Type type, ChatSession session) {
if (eventPublisher != null && session != null) {
eventPublisher.publishEvent(new ChatEvent(this, type, session));
Expand Down Expand Up @@ -214,6 +223,97 @@ public ChatSession accept(Long sessionId, Long agentId) {
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. Announces {@code chat.transferred} with the previous agent,
* the new agent and the new department.
*
* @throws EntityNotFoundException for an unknown session
* @throws IllegalArgumentException when neither target is given, the chat
* has ended, the department does not exist, or the agent is not
* active staff who can be assigned the ticket
*/
@Transactional(transactionManager = EscalatedTransactionManagers.ESCALATED)
public ChatSession transfer(Long sessionId, Long toAgentId, Long toDepartmentId) {
ChatSession session = findById(sessionId);

if ("ended".equals(session.getStatus())) {
throw new IllegalArgumentException("Chat session has already ended.");
}
if (toAgentId == null && toDepartmentId == null) {
throw new IllegalArgumentException("Provide an agent_id or department_id.");
}

dev.escalated.models.Department department = null;
if (toDepartmentId != null) {
department = departments.findById(toDepartmentId)
.orElseThrow(() -> new IllegalArgumentException("The selected department_id is invalid."));
}
dev.escalated.models.AgentProfile agent = null;
if (toAgentId != null) {
agent = agentProfiles.findById(toAgentId)
.filter(candidate -> candidate.isActive() && (candidate.isAgent() || candidate.isAdmin()))
.orElseThrow(() -> new IllegalArgumentException("Agent cannot access this ticket."));
}

Ticket ticket = ticketService.findById(session.getTicketId());
Long fromAgentId = session.getAgentId();
final dev.escalated.models.Department targetDepartment = department;
final dev.escalated.models.AgentProfile targetAgent = agent;
java.util.function.Supplier<Ticket> move = () -> {
if (targetDepartment != null) {
ticket.setDepartment(targetDepartment);
}
// Checked against the ticket in its new department.
if (targetAgent != null && access != null && !access.canAssign(ticket, targetAgent)) {
throw new IllegalArgumentException("Agent cannot access this ticket.");
}
ticket.setAssignedAgent(targetAgent);
return tickets.save(ticket);
};
// A refusal rolls the department change back with the transaction.
if (access == null) {
move.get();
} else {
access.withoutRestriction(move);
}

if (toDepartmentId != null) {
session.setDepartmentId(toDepartmentId);
}
if (toAgentId != null) {
session.setAgentId(toAgentId);
} else {
session.setAgentId(null);
session.setStatus("waiting");
session.setAcceptedAt(null);
}
session = chatSessionRepository.save(session);

broadcast("/topic/chat/" + sessionId, session);
if (eventPublisher != null) {
eventPublisher.publishEvent(new ChatEvent(this, ChatEvent.Type.TRANSFERRED, session,
new ChatEvent.Transfer(fromAgentId, toAgentId, toDepartmentId)));
}

if (toAgentId == null) {
ChatRoutingService.RouteResult route = chatRoutingService.resolve(toDepartmentId);
boolean eligible = route.agentId() != null
&& agentProfiles.findById(route.agentId())
.filter(candidate -> access == null || access.canAssign(ticket, candidate))
.isPresent();
if (eligible) {
session = accept(sessionId, route.agentId());
}
}

return session;
}

/**
* Send a message within a chat session.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/dev/escalated/ui/UiRoutes.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public UiRoutes() {
register("escalated.agent.tickets.custom-action", "/agent/tickets/{ticket}/actions/{action}");
// End Chat on the agent ticket page; Laravel serves it at /admin/chat/{session}/end under this name.
register("escalated.admin.chat.end", "/agent/chat/{session}/end");
// Live chat transfer from TicketShow's action bar, from the agent's browser session like End Chat.
register("escalated.admin.chat.transfer", "/agent/chat/{session}/transfer");
// Customer
register("escalated.customer.tickets.index", "");
register("escalated.customer.tickets.create", "/create");
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/dev/escalated/ui/controllers/AgentUiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ public ResponseEntity<Map<String, String>> endChat(@PathVariable("session") Long
return ResponseEntity.ok(Map.of("message", "Chat ended."));
}

/**
* Transfer from TicketShow's action bar ({@code escalated.admin.chat.transfer}): the chat
* session named by id, from the agent's browser session, as the API's
* {@code POST /escalated/api/agent/chat/{id}/transfer} does. 200, 422 or 404.
*/
@PostMapping("/chat/{session}/transfer")
public ResponseEntity<Map<String, Object>> transferChat(
@PathVariable("session") Long sessionId,
@org.springframework.web.bind.annotation.RequestBody(required = false) Map<String, Object> body) {
return dev.escalated.controllers.agent.AgentChatController.transfer(chats, sessionId, body);
}

@PostMapping("/tickets/{ticket}/actions/{action}")
public ResponseEntity<Void> customAction(@PathVariable("ticket") String reference, @PathVariable String action,
HttpServletRequest request, Authentication authentication) {
Expand Down
Loading
Loading