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
42 changes: 42 additions & 0 deletions src/main/java/dev/escalated/models/TicketFollower.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<TicketFollower, Long> {

boolean existsByTicketIdAndUserId(Long ticketId, String userId);

List<TicketFollower> findByTicketId(Long ticketId);
}
34 changes: 34 additions & 0 deletions src/main/java/dev/escalated/services/FollowerRecipients.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> resolve(List<String> userIds, String excludeUserId) {
List<String> result = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String userId : userIds) {
if (userId.equals(excludeUserId) || !seen.add(userId)) {
continue;
}
result.add(userId);
}
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
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;
import dev.escalated.repositories.DeferredWorkflowJobRepository;
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;
Expand Down Expand Up @@ -61,20 +63,23 @@ public class WorkflowExecutorService {
private final DepartmentRepository departmentRepository;
private final ReplyRepository replyRepository;
private final DeferredWorkflowJobRepository deferredRepository;
private final TicketFollowerRepository ticketFollowerRepository;

public WorkflowExecutorService(
TicketRepository ticketRepository,
TagRepository tagRepository,
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;
}

/**
Expand Down Expand Up @@ -134,10 +139,20 @@ private void dispatch(Ticket ticket, Map<String, Object> 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()));
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
21 changes: 21 additions & 0 deletions src/test/java/dev/escalated/services/FollowerRecipientsTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
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;
import dev.escalated.repositories.DeferredWorkflowJobRepository;
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;
Expand Down Expand Up @@ -48,14 +50,16 @@ class WorkflowExecutorServiceTest {
@Mock private DepartmentRepository departmentRepository;
@Mock private ReplyRepository replyRepository;
@Mock private DeferredWorkflowJobRepository deferredRepository;
@Mock private TicketFollowerRepository ticketFollowerRepository;

private WorkflowExecutorService executor;

@BeforeEach
void setUp() {
executor = new WorkflowExecutorService(
ticketRepository, tagRepository, agentRepository,
departmentRepository, replyRepository, deferredRepository);
departmentRepository, replyRepository, deferredRepository,
ticketFollowerRepository);
}

private Ticket newTicket() {
Expand All @@ -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();
Expand Down