From 1d09efc3d4e299998f3de14fd32df225f0c9f17d Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:23:15 -0400 Subject: [PATCH] feat(workflow): add WorkflowExecutorService for action dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the NestJS workflow-executor.service.ts to Spring. The existing WorkflowEngine only evaluates conditions; this service parses the JSON action array on Workflow.actions and dispatches each entry against the relevant repository. Action catalog: change_priority, change_status, assign_agent, set_department, add_tag, remove_tag, add_note, insert_canned_reply. {{field}} placeholders in canned replies are interpolated via WorkflowEngine.interpolateVariables. One failing action does not halt the others — mirrors the NestJS reference. Unknown action types warn-log and skip. Follow-up PR will add the WorkflowRunner (loads matching workflows, evaluates, writes WorkflowLog) and the event listener that bridges TicketEvent into processEvent. --- .../services/WorkflowExecutorService.java | 262 +++++++++++++++ .../services/WorkflowExecutorServiceTest.java | 298 ++++++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 src/main/java/dev/escalated/services/WorkflowExecutorService.java create mode 100644 src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java diff --git a/src/main/java/dev/escalated/services/WorkflowExecutorService.java b/src/main/java/dev/escalated/services/WorkflowExecutorService.java new file mode 100644 index 0000000..d0af6e7 --- /dev/null +++ b/src/main/java/dev/escalated/services/WorkflowExecutorService.java @@ -0,0 +1,262 @@ +package dev.escalated.services; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.escalated.models.AgentProfile; +import dev.escalated.models.Department; +import dev.escalated.models.Reply; +import dev.escalated.models.Tag; +import dev.escalated.models.Ticket; +import dev.escalated.models.TicketPriority; +import dev.escalated.models.TicketStatus; +import dev.escalated.repositories.AgentProfileRepository; +import dev.escalated.repositories.DepartmentRepository; +import dev.escalated.repositories.ReplyRepository; +import dev.escalated.repositories.TagRepository; +import dev.escalated.repositories.TicketRepository; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Performs the side-effects dictated by a matched {@code Workflow}. + * + *

Distinct from {@link WorkflowEngine}, which only evaluates + * conditions. This service parses the JSON action array stored on + * {@code Workflow.actions} and dispatches each entry against the + * relevant repository. + * + *

Action catalog: {@code change_priority}, {@code change_status}, + * {@code assign_agent}, {@code set_department}, {@code add_tag}, + * {@code remove_tag}, {@code add_note}, {@code insert_canned_reply}. + * Mirrors the NestJS reference impl in + * {@code escalated-nestjs/src/services/workflow-executor.service.ts}. + * + *

Unknown or malformed actions are logged at {@code warn} and + * skipped — one bad action never halts execution of the other + * actions on the same workflow. + */ +@Service +public class WorkflowExecutorService { + + private static final Logger log = LoggerFactory.getLogger(WorkflowExecutorService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final TicketRepository ticketRepository; + private final TagRepository tagRepository; + private final AgentProfileRepository agentRepository; + private final DepartmentRepository departmentRepository; + private final ReplyRepository replyRepository; + + public WorkflowExecutorService( + TicketRepository ticketRepository, + TagRepository tagRepository, + AgentProfileRepository agentRepository, + DepartmentRepository departmentRepository, + ReplyRepository replyRepository) { + this.ticketRepository = ticketRepository; + this.tagRepository = tagRepository; + this.agentRepository = agentRepository; + this.departmentRepository = departmentRepository; + this.replyRepository = replyRepository; + } + + /** + * Execute every action in {@code actionsJson} against {@code ticket}. + * Returns the list of parsed action maps so callers (e.g. the + * runner) can serialize them into a {@code WorkflowLog} audit row. + * + * @param actionsJson the JSON string stored on {@code Workflow.actions} + * @return parsed actions (never null; empty on malformed input) + */ + public List> execute(Ticket ticket, String actionsJson) { + List> actions = parseActions(actionsJson); + for (Map action : actions) { + try { + dispatch(ticket, action); + } catch (RuntimeException ex) { + log.warn("[WorkflowExecutor] action {} failed on ticket #{}: {}", + action.get("type"), ticket.getId(), ex.getMessage()); + } + } + return actions; + } + + private List> parseActions(String actionsJson) { + if (actionsJson == null || actionsJson.isBlank()) { + return List.of(); + } + try { + return MAPPER.readValue(actionsJson, new TypeReference>>() {}); + } catch (Exception ex) { + log.warn("[WorkflowExecutor] failed to parse actions JSON: {}", ex.getMessage()); + return List.of(); + } + } + + private void dispatch(Ticket ticket, Map action) { + String type = String.valueOf(action.getOrDefault("type", "")); + String value = action.get("value") == null ? "" : String.valueOf(action.get("value")); + switch (type) { + case "change_priority" -> changePriority(ticket, value); + case "change_status" -> changeStatus(ticket, value); + case "assign_agent" -> assignAgent(ticket, value); + case "set_department" -> setDepartment(ticket, value); + case "add_tag" -> addTag(ticket, value); + case "remove_tag" -> removeTag(ticket, value); + case "add_note" -> addNote(ticket, value); + case "insert_canned_reply" -> insertCannedReply(ticket, value); + default -> log.warn("[WorkflowExecutor] unknown action type: {}", type); + } + } + + private void changePriority(Ticket ticket, String value) { + try { + ticket.setPriority(TicketPriority.valueOf(value.toUpperCase())); + ticketRepository.save(ticket); + } catch (IllegalArgumentException ex) { + log.warn("[WorkflowExecutor] change_priority: invalid priority '{}'", value); + } + } + + private void changeStatus(Ticket ticket, String value) { + try { + ticket.setStatus(TicketStatus.valueOf(value.toUpperCase())); + ticketRepository.save(ticket); + } catch (IllegalArgumentException ex) { + log.warn("[WorkflowExecutor] change_status: invalid status '{}'", value); + } + } + + private void assignAgent(Ticket ticket, String value) { + Long agentId = parseLong(value); + if (agentId == null) { + return; + } + Optional agent = agentRepository.findById(agentId); + if (agent.isEmpty()) { + log.warn("[WorkflowExecutor] assign_agent: agent #{} not found", agentId); + return; + } + ticket.setAssignedAgent(agent.get()); + ticketRepository.save(ticket); + } + + private void setDepartment(Ticket ticket, String value) { + Long deptId = parseLong(value); + if (deptId == null) { + return; + } + Optional dept = departmentRepository.findById(deptId); + if (dept.isEmpty()) { + log.warn("[WorkflowExecutor] set_department: department #{} not found", deptId); + return; + } + ticket.setDepartment(dept.get()); + ticketRepository.save(ticket); + } + + private void addTag(Ticket ticket, String value) { + Tag tag = resolveTag(value); + if (tag == null) { + log.warn("[WorkflowExecutor] add_tag: tag '{}' not found", value); + return; + } + ticket.getTags().add(tag); + ticketRepository.save(ticket); + } + + private void removeTag(Ticket ticket, String value) { + Tag tag = resolveTag(value); + if (tag == null) { + return; + } + ticket.getTags().removeIf(t -> t.getId().equals(tag.getId())); + ticketRepository.save(ticket); + } + + private Tag resolveTag(String value) { + Optional byName = tagRepository.findByName(value); + if (byName.isPresent()) { + return byName.get(); + } + Long asId = parseLong(value); + if (asId != null) { + return tagRepository.findById(asId).orElse(null); + } + return null; + } + + private void addNote(Ticket ticket, String body) { + if (body == null || body.isBlank()) { + return; + } + Reply note = new Reply(); + note.setTicket(ticket); + note.setBody(body); + note.setAuthorType("system"); + note.setInternal(true); + replyRepository.save(note); + } + + /** + * Insert an agent-visible reply built from a template. {@code {{field}}} + * placeholders are interpolated against the ticket via + * {@link WorkflowEngine#interpolateVariables}. Unknown variables stay + * as literal {@code {{...}}} so the reader can see the gap. + */ + private void insertCannedReply(Ticket ticket, String template) { + if (template == null || template.isBlank()) { + return; + } + Map ticketMap = ticketToMap(ticket); + String body = WorkflowEngine.interpolateVariables(template, ticketMap); + Reply reply = new Reply(); + reply.setTicket(ticket); + reply.setBody(body); + reply.setAuthorType("system"); + reply.setInternal(false); + replyRepository.save(reply); + } + + private static Map ticketToMap(Ticket ticket) { + Map map = new HashMap<>(); + if (ticket.getSubject() != null) { + map.put("subject", ticket.getSubject()); + } + if (ticket.getBody() != null) { + map.put("body", ticket.getBody()); + } + if (ticket.getTicketNumber() != null) { + map.put("ticket_number", ticket.getTicketNumber()); + } + if (ticket.getRequesterName() != null) { + map.put("requester_name", ticket.getRequesterName()); + } + if (ticket.getRequesterEmail() != null) { + map.put("requester_email", ticket.getRequesterEmail()); + } + if (ticket.getPriority() != null) { + map.put("priority", ticket.getPriority().name().toLowerCase()); + } + if (ticket.getStatus() != null) { + map.put("status", ticket.getStatus().name().toLowerCase()); + } + return map; + } + + private static Long parseLong(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException ex) { + return null; + } + } +} diff --git a/src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java b/src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java new file mode 100644 index 0000000..fe5a775 --- /dev/null +++ b/src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java @@ -0,0 +1,298 @@ +package dev.escalated.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.escalated.models.AgentProfile; +import dev.escalated.models.Department; +import dev.escalated.models.Reply; +import dev.escalated.models.Tag; +import dev.escalated.models.Ticket; +import dev.escalated.models.TicketPriority; +import dev.escalated.models.TicketStatus; +import dev.escalated.repositories.AgentProfileRepository; +import dev.escalated.repositories.DepartmentRepository; +import dev.escalated.repositories.ReplyRepository; +import dev.escalated.repositories.TagRepository; +import dev.escalated.repositories.TicketRepository; +import java.util.HashSet; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link WorkflowExecutorService}. Mocks every + * repository — no Spring context. Mirrors the test coverage of the + * NestJS reference {@code workflow-executor.service.ts} and covers + * each action type in the catalog plus the malformed-input paths. + */ +@ExtendWith(MockitoExtension.class) +class WorkflowExecutorServiceTest { + + @Mock private TicketRepository ticketRepository; + @Mock private TagRepository tagRepository; + @Mock private AgentProfileRepository agentRepository; + @Mock private DepartmentRepository departmentRepository; + @Mock private ReplyRepository replyRepository; + + private WorkflowExecutorService executor; + + @BeforeEach + void setUp() { + executor = new WorkflowExecutorService( + ticketRepository, tagRepository, agentRepository, + departmentRepository, replyRepository); + } + + private Ticket newTicket() { + Ticket t = new Ticket(); + t.setId(1L); + t.setSubject("Help"); + t.setBody("body"); + t.setTicketNumber("ESC-00001"); + t.setRequesterName("Alice"); + t.setRequesterEmail("alice@example.com"); + t.setPriority(TicketPriority.LOW); + t.setStatus(TicketStatus.OPEN); + t.setTags(new HashSet<>()); + return t; + } + + @Test + void execute_changePriority_updatesTicket() { + Ticket ticket = newTicket(); + + executor.execute(ticket, "[{\"type\":\"change_priority\",\"value\":\"high\"}]"); + + assertThat(ticket.getPriority()).isEqualTo(TicketPriority.HIGH); + verify(ticketRepository).save(ticket); + } + + @Test + void execute_changeStatus_updatesTicket() { + Ticket ticket = newTicket(); + + executor.execute(ticket, "[{\"type\":\"change_status\",\"value\":\"resolved\"}]"); + + assertThat(ticket.getStatus()).isEqualTo(TicketStatus.RESOLVED); + verify(ticketRepository).save(ticket); + } + + @Test + void execute_assignAgent_looksUpAndAssigns() { + Ticket ticket = newTicket(); + AgentProfile agent = new AgentProfile(); + agent.setId(7L); + + when(agentRepository.findById(7L)).thenReturn(Optional.of(agent)); + + executor.execute(ticket, "[{\"type\":\"assign_agent\",\"value\":\"7\"}]"); + + assertThat(ticket.getAssignedAgent()).isSameAs(agent); + verify(ticketRepository).save(ticket); + } + + @Test + void execute_assignAgent_missingAgentDoesNotSave() { + Ticket ticket = newTicket(); + when(agentRepository.findById(99L)).thenReturn(Optional.empty()); + + executor.execute(ticket, "[{\"type\":\"assign_agent\",\"value\":\"99\"}]"); + + assertThat(ticket.getAssignedAgent()).isNull(); + verify(ticketRepository, never()).save(any()); + } + + @Test + void execute_setDepartment_looksUpAndAssigns() { + Ticket ticket = newTicket(); + Department dept = new Department(); + dept.setId(3L); + + when(departmentRepository.findById(3L)).thenReturn(Optional.of(dept)); + + executor.execute(ticket, "[{\"type\":\"set_department\",\"value\":\"3\"}]"); + + assertThat(ticket.getDepartment()).isSameAs(dept); + verify(ticketRepository).save(ticket); + } + + @Test + void execute_addTag_byName_addsToTicket() { + Ticket ticket = newTicket(); + Tag tag = new Tag(); + tag.setId(5L); + tag.setName("urgent"); + + when(tagRepository.findByName("urgent")).thenReturn(Optional.of(tag)); + + executor.execute(ticket, "[{\"type\":\"add_tag\",\"value\":\"urgent\"}]"); + + assertThat(ticket.getTags()).contains(tag); + verify(ticketRepository).save(ticket); + } + + @Test + void execute_addTag_byId_fallsBackWhenNameMisses() { + Ticket ticket = newTicket(); + Tag tag = new Tag(); + tag.setId(5L); + tag.setName("urgent"); + + when(tagRepository.findByName("5")).thenReturn(Optional.empty()); + when(tagRepository.findById(5L)).thenReturn(Optional.of(tag)); + + executor.execute(ticket, "[{\"type\":\"add_tag\",\"value\":\"5\"}]"); + + assertThat(ticket.getTags()).contains(tag); + } + + @Test + void execute_addTag_unknownTagSkipped() { + Ticket ticket = newTicket(); + when(tagRepository.findByName("missing")).thenReturn(Optional.empty()); + + executor.execute(ticket, "[{\"type\":\"add_tag\",\"value\":\"missing\"}]"); + + assertThat(ticket.getTags()).isEmpty(); + verify(ticketRepository, never()).save(any()); + } + + @Test + void execute_removeTag_removesFromTicket() { + Ticket ticket = newTicket(); + Tag tag = new Tag(); + tag.setId(5L); + tag.setName("urgent"); + ticket.getTags().add(tag); + + when(tagRepository.findByName("urgent")).thenReturn(Optional.of(tag)); + + executor.execute(ticket, "[{\"type\":\"remove_tag\",\"value\":\"urgent\"}]"); + + assertThat(ticket.getTags()).doesNotContain(tag); + verify(ticketRepository).save(ticket); + } + + @Test + void execute_addNote_persistsInternalReply() { + Ticket ticket = newTicket(); + + executor.execute(ticket, "[{\"type\":\"add_note\",\"value\":\"internal\"}]"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Reply.class); + verify(replyRepository).save(captor.capture()); + Reply saved = captor.getValue(); + assertThat(saved.getBody()).isEqualTo("internal"); + assertThat(saved.isInternal()).isTrue(); + assertThat(saved.getTicket()).isSameAs(ticket); + } + + @Test + void execute_addNote_blankSkipped() { + Ticket ticket = newTicket(); + + executor.execute(ticket, "[{\"type\":\"add_note\",\"value\":\" \"}]"); + + verify(replyRepository, never()).save(any()); + } + + @Test + void execute_insertCannedReply_interpolatesAndSavesPublicReply() { + Ticket ticket = newTicket(); + + executor.execute(ticket, + "[{\"type\":\"insert_canned_reply\",\"value\":\"Hi {{requester_name}}, ref {{ticket_number}}\"}]"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Reply.class); + verify(replyRepository).save(captor.capture()); + Reply saved = captor.getValue(); + assertThat(saved.getBody()).isEqualTo("Hi Alice, ref ESC-00001"); + assertThat(saved.isInternal()).isFalse(); + } + + @Test + void execute_insertCannedReply_unknownVariableLeftLiteral() { + Ticket ticket = newTicket(); + + executor.execute(ticket, + "[{\"type\":\"insert_canned_reply\",\"value\":\"Hi {{unknown}}\"}]"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Reply.class); + verify(replyRepository).save(captor.capture()); + assertThat(captor.getValue().getBody()).isEqualTo("Hi {{unknown}}"); + } + + @Test + void execute_unknownActionTypeIsSkipped() { + Ticket ticket = newTicket(); + + executor.execute(ticket, "[{\"type\":\"future_action\",\"value\":\"x\"}]"); + + verify(ticketRepository, never()).save(any()); + verify(replyRepository, never()).save(any()); + } + + @Test + void execute_malformedJsonReturnsEmptyActions() { + Ticket ticket = newTicket(); + + var result = executor.execute(ticket, "not json"); + + assertThat(result).isEmpty(); + verify(ticketRepository, never()).save(any()); + } + + @Test + void execute_emptyStringReturnsEmptyActions() { + Ticket ticket = newTicket(); + + var result = executor.execute(ticket, ""); + + assertThat(result).isEmpty(); + } + + @Test + void execute_nullReturnsEmptyActions() { + Ticket ticket = newTicket(); + + var result = executor.execute(ticket, null); + + assertThat(result).isEmpty(); + } + + @Test + void execute_oneActionFailureDoesNotStopOthers() { + Ticket ticket = newTicket(); + when(agentRepository.findById(anyLong())) + .thenThrow(new RuntimeException("db offline")); + + executor.execute(ticket, + "[{\"type\":\"assign_agent\",\"value\":\"7\"}," + + "{\"type\":\"change_priority\",\"value\":\"urgent\"}]"); + + // Despite the failure on assign_agent, change_priority still ran. + assertThat(ticket.getPriority()).isEqualTo(TicketPriority.URGENT); + } + + @Test + void execute_returnsParsedActionList() { + Ticket ticket = newTicket(); + + var result = executor.execute(ticket, + "[{\"type\":\"change_priority\",\"value\":\"high\"}," + + "{\"type\":\"add_note\",\"value\":\"go\"}]"); + + assertThat(result).hasSize(2); + assertThat(result.get(0)).containsEntry("type", "change_priority"); + assertThat(result.get(1)).containsEntry("type", "add_note"); + } +}