From fc53dd8863afbaf46e7591576519fb7f000a4709 Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Tue, 30 Jun 2026 01:24:57 -0400 Subject: [PATCH] feat(followers): TicketFollower entity + add_follower handler Spring had add_follower whitelisted in WorkflowEngine.ACTION_TYPES but unhandled (the dispatch switch hit the default warn-log), with no followers table. - TicketFollower JPA entity + TicketFollowerRepository. - Flyway migration V10: escalated_ticket_followers table. - FollowerRecipients.resolve() (exclude actor + dedup, unit tested). - WorkflowExecutorService now handles add_follower (idempotently records the follower), with handler tests. Spring resolves no follower recipients in its own notifications, so followers are recorded and the resolved recipients exposed for the host to deliver to. Closes #74 --- .../dev/escalated/models/TicketFollower.java | 42 +++++++++++++++++++ .../TicketFollowerRepository.java | 14 +++++++ .../services/FollowerRecipients.java | 34 +++++++++++++++ .../services/WorkflowExecutorService.java | 17 +++++++- ...V10__create_escalated_ticket_followers.sql | 15 +++++++ .../services/FollowerRecipientsTest.java | 21 ++++++++++ .../services/WorkflowExecutorServiceTest.java | 26 +++++++++++- 7 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 src/main/java/dev/escalated/models/TicketFollower.java create mode 100644 src/main/java/dev/escalated/repositories/TicketFollowerRepository.java create mode 100644 src/main/java/dev/escalated/services/FollowerRecipients.java create mode 100644 src/main/resources/db/migration/V10__create_escalated_ticket_followers.sql create mode 100644 src/test/java/dev/escalated/services/FollowerRecipientsTest.java diff --git a/src/main/java/dev/escalated/models/TicketFollower.java b/src/main/java/dev/escalated/models/TicketFollower.java new file mode 100644 index 0000000..f630343 --- /dev/null +++ b/src/main/java/dev/escalated/models/TicketFollower.java @@ -0,0 +1,42 @@ +package dev.escalated.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; + +/** + * A host user following a ticket — a notification target alongside the assignee + * and requester. Recorded via the add_follower workflow action. Unique per + * (ticket_id, user_id). See issue #74. + */ +@Entity +@Table( + name = "escalated_ticket_followers", + uniqueConstraints = @UniqueConstraint(columnNames = {"ticket_id", "user_id"})) +public class TicketFollower extends BaseEntity { + + @NotNull + @Column(name = "ticket_id", nullable = false) + private Long ticketId; + + @NotNull + @Column(name = "user_id", nullable = false) + private String userId; + + protected TicketFollower() {} + + public TicketFollower(Long ticketId, String userId) { + this.ticketId = ticketId; + this.userId = userId; + } + + public Long getTicketId() { + return ticketId; + } + + public String getUserId() { + return userId; + } +} diff --git a/src/main/java/dev/escalated/repositories/TicketFollowerRepository.java b/src/main/java/dev/escalated/repositories/TicketFollowerRepository.java new file mode 100644 index 0000000..8b6a4ca --- /dev/null +++ b/src/main/java/dev/escalated/repositories/TicketFollowerRepository.java @@ -0,0 +1,14 @@ +package dev.escalated.repositories; + +import dev.escalated.models.TicketFollower; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TicketFollowerRepository extends JpaRepository { + + boolean existsByTicketIdAndUserId(Long ticketId, String userId); + + List findByTicketId(Long ticketId); +} diff --git a/src/main/java/dev/escalated/services/FollowerRecipients.java b/src/main/java/dev/escalated/services/FollowerRecipients.java new file mode 100644 index 0000000..67847e6 --- /dev/null +++ b/src/main/java/dev/escalated/services/FollowerRecipients.java @@ -0,0 +1,34 @@ +package dev.escalated.services; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Resolves the recipient user ids for a ticket's followers. + * + *

The package abstracts the host user table, so it cannot email follower + * users itself — these ids are exposed for the host app to deliver to. See + * issue #74. + */ +public final class FollowerRecipients { + + private FollowerRecipients() {} + + /** + * Excludes the actor (a user is never notified of their own action) and + * de-duplicates the given user ids, preserving order. + */ + public static List resolve(List userIds, String excludeUserId) { + List result = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (String userId : userIds) { + if (userId.equals(excludeUserId) || !seen.add(userId)) { + continue; + } + result.add(userId); + } + return result; + } +} diff --git a/src/main/java/dev/escalated/services/WorkflowExecutorService.java b/src/main/java/dev/escalated/services/WorkflowExecutorService.java index 26fd560..732b72e 100644 --- a/src/main/java/dev/escalated/services/WorkflowExecutorService.java +++ b/src/main/java/dev/escalated/services/WorkflowExecutorService.java @@ -8,6 +8,7 @@ import dev.escalated.models.Reply; import dev.escalated.models.Tag; import dev.escalated.models.Ticket; +import dev.escalated.models.TicketFollower; import dev.escalated.models.TicketPriority; import dev.escalated.models.TicketStatus; import dev.escalated.repositories.AgentProfileRepository; @@ -15,6 +16,7 @@ import dev.escalated.repositories.DepartmentRepository; import dev.escalated.repositories.ReplyRepository; import dev.escalated.repositories.TagRepository; +import dev.escalated.repositories.TicketFollowerRepository; import dev.escalated.repositories.TicketRepository; import java.time.Instant; import java.util.HashMap; @@ -61,6 +63,7 @@ public class WorkflowExecutorService { private final DepartmentRepository departmentRepository; private final ReplyRepository replyRepository; private final DeferredWorkflowJobRepository deferredRepository; + private final TicketFollowerRepository ticketFollowerRepository; public WorkflowExecutorService( TicketRepository ticketRepository, @@ -68,13 +71,15 @@ public WorkflowExecutorService( AgentProfileRepository agentRepository, DepartmentRepository departmentRepository, ReplyRepository replyRepository, - DeferredWorkflowJobRepository deferredRepository) { + DeferredWorkflowJobRepository deferredRepository, + TicketFollowerRepository ticketFollowerRepository) { this.ticketRepository = ticketRepository; this.tagRepository = tagRepository; this.agentRepository = agentRepository; this.departmentRepository = departmentRepository; this.replyRepository = replyRepository; this.deferredRepository = deferredRepository; + this.ticketFollowerRepository = ticketFollowerRepository; } /** @@ -134,10 +139,20 @@ private void dispatch(Ticket ticket, Map action) { case "remove_tag" -> removeTag(ticket, value); case "add_note" -> addNote(ticket, value); case "insert_canned_reply" -> insertCannedReply(ticket, value); + case "add_follower" -> addFollower(ticket, value); default -> log.warn("[WorkflowExecutor] unknown action type: {}", type); } } + private void addFollower(Ticket ticket, String value) { + if (value == null || value.isBlank() || "0".equals(value)) { + return; + } + if (!ticketFollowerRepository.existsByTicketIdAndUserId(ticket.getId(), value)) { + ticketFollowerRepository.save(new TicketFollower(ticket.getId(), value)); + } + } + private void changePriority(Ticket ticket, String value) { try { ticket.setPriority(TicketPriority.valueOf(value.toUpperCase())); diff --git a/src/main/resources/db/migration/V10__create_escalated_ticket_followers.sql b/src/main/resources/db/migration/V10__create_escalated_ticket_followers.sql new file mode 100644 index 0000000..55349a2 --- /dev/null +++ b/src/main/resources/db/migration/V10__create_escalated_ticket_followers.sql @@ -0,0 +1,15 @@ +-- Ticket followers — host users who follow a ticket and are a notification +-- target alongside the assignee and requester. user_id is VARCHAR so integer, +-- UUID, ULID, or other string host keys all work. See issue #74. + +CREATE TABLE escalated_ticket_followers ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + ticket_id BIGINT NOT NULL, + user_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_escalated_ticket_followers_ticket FOREIGN KEY (ticket_id) REFERENCES escalated_tickets (id) ON DELETE CASCADE, + CONSTRAINT uq_escalated_ticket_followers_ticket_user UNIQUE (ticket_id, user_id) +); + +CREATE INDEX idx_escalated_ticket_followers_user ON escalated_ticket_followers (user_id); diff --git a/src/test/java/dev/escalated/services/FollowerRecipientsTest.java b/src/test/java/dev/escalated/services/FollowerRecipientsTest.java new file mode 100644 index 0000000..aa1ea5a --- /dev/null +++ b/src/test/java/dev/escalated/services/FollowerRecipientsTest.java @@ -0,0 +1,21 @@ +package dev.escalated.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FollowerRecipientsTest { + + @Test + void excludesActorAndDeduplicates() { + assertEquals( + List.of("7", "3"), FollowerRecipients.resolve(List.of("7", "2", "7", "3"), "2")); + } + + @Test + void keepsAllDeduplicatedWhenNoActorExcluded() { + assertEquals(List.of("7", "3"), FollowerRecipients.resolve(Arrays.asList("7", "3", "7"), null)); + } +} diff --git a/src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java b/src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java index dcc165f..dbbf9cc 100644 --- a/src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java +++ b/src/test/java/dev/escalated/services/WorkflowExecutorServiceTest.java @@ -14,6 +14,7 @@ import dev.escalated.models.Reply; import dev.escalated.models.Tag; import dev.escalated.models.Ticket; +import dev.escalated.models.TicketFollower; import dev.escalated.models.TicketPriority; import dev.escalated.models.TicketStatus; import dev.escalated.repositories.AgentProfileRepository; @@ -21,6 +22,7 @@ import dev.escalated.repositories.DepartmentRepository; import dev.escalated.repositories.ReplyRepository; import dev.escalated.repositories.TagRepository; +import dev.escalated.repositories.TicketFollowerRepository; import dev.escalated.repositories.TicketRepository; import java.time.Instant; import java.util.HashSet; @@ -48,6 +50,7 @@ class WorkflowExecutorServiceTest { @Mock private DepartmentRepository departmentRepository; @Mock private ReplyRepository replyRepository; @Mock private DeferredWorkflowJobRepository deferredRepository; + @Mock private TicketFollowerRepository ticketFollowerRepository; private WorkflowExecutorService executor; @@ -55,7 +58,8 @@ class WorkflowExecutorServiceTest { void setUp() { executor = new WorkflowExecutorService( ticketRepository, tagRepository, agentRepository, - departmentRepository, replyRepository, deferredRepository); + departmentRepository, replyRepository, deferredRepository, + ticketFollowerRepository); } private Ticket newTicket() { @@ -82,6 +86,26 @@ void execute_changePriority_updatesTicket() { verify(ticketRepository).save(ticket); } + @Test + void execute_addFollower_recordsFollower() { + Ticket ticket = newTicket(); + when(ticketFollowerRepository.existsByTicketIdAndUserId(1L, "7")).thenReturn(false); + + executor.execute(ticket, "[{\"type\":\"add_follower\",\"value\":\"7\"}]"); + + verify(ticketFollowerRepository).save(any(TicketFollower.class)); + } + + @Test + void execute_addFollower_skipsWhenAlreadyFollowing() { + Ticket ticket = newTicket(); + when(ticketFollowerRepository.existsByTicketIdAndUserId(1L, "7")).thenReturn(true); + + executor.execute(ticket, "[{\"type\":\"add_follower\",\"value\":\"7\"}]"); + + verify(ticketFollowerRepository, never()).save(any()); + } + @Test void execute_changeStatus_updatesTicket() { Ticket ticket = newTicket();