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();