From 17735c81fe8ad0f4b3414ef1cf804b1a2fca010b Mon Sep 17 00:00:00 2001 From: Shadi Date: Wed, 17 Jun 2026 21:21:12 +0200 Subject: [PATCH 1/5] Implement ResendAutomationService for email notifications and add related DTOs and exception handling --- .../response/EmailNotificationResponse.java | 8 ++ .../email/EmailSendResult.java | 8 ++ .../email/EmailSendingException.java | 12 ++ .../email/ResendAutomationService.java | 129 ++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java create mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java create mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java create mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java new file mode 100644 index 0000000..83d059d --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java @@ -0,0 +1,8 @@ +package net.hackyourfuture.tickettrackingsystem.dto.response; + +public record EmailNotificationResponse( + String recipientEmail, + boolean sent, + String message +) { +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java new file mode 100644 index 0000000..55fa6ad --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java @@ -0,0 +1,8 @@ +package net.hackyourfuture.tickettrackingsystem.email; + +public record EmailSendResult( + String recipientEmail, + boolean sent, + String message +) { +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java new file mode 100644 index 0000000..3bed86b --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java @@ -0,0 +1,12 @@ +package net.hackyourfuture.tickettrackingsystem.email; + +public class EmailSendingException extends RuntimeException { + + public EmailSendingException(String message) { + super(message); + } + + public EmailSendingException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java new file mode 100644 index 0000000..32147c4 --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java @@ -0,0 +1,129 @@ +package net.hackyourfuture.tickettrackingsystem.email; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +@Service +public class ResendAutomationService { + + private static final Logger logger = + LoggerFactory.getLogger(ResendAutomationService.class); + + private static final String RESEND_EMAIL_URL = "https://api.resend.com/emails"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @Value("${resend.api-key}") + private String apiKey; + + @Value("${resend.from-email}") + private String fromEmail; + + public EmailSendResult sendTicketUpdatedEmail( + String assigneeEmail, + Long ticketId, + String ticketTitle, + String ticketStatus, + String updatedBy, + String changes + ) { + try { + String subject = "Ticket #" + ticketId + " updated"; + + String html = """ +

Ticket updated

+ +

Ticket ID: %s

+

Title: %s

+

Status: %s

+

Updated by: %s

+

Changes:

+
%s
+ """.formatted( + ticketId, + escapeHtml(ticketTitle), + escapeHtml(ticketStatus), + escapeHtml(updatedBy), + escapeHtml(changes) + ); + + Map requestBody = Map.of( + "from", fromEmail, + "to", assigneeEmail, + "subject", subject, + "html", html + ); + + String jsonBody = objectMapper.writeValueAsString(requestBody); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RESEND_EMAIL_URL)) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + String message = "Resend failed with status " + + response.statusCode() + + ". Body: " + + response.body(); + + logger.warn("Email sending failed for {}. {}", assigneeEmail, message); + + return new EmailSendResult( + assigneeEmail, + false, + message + ); + } + + logger.info("Email sent successfully to {}", assigneeEmail); + + return new EmailSendResult( + assigneeEmail, + true, + "Email sent successfully" + ); + + } catch (Exception exception) { + String message = "Could not send email: " + exception.getMessage(); + + logger.error("Email sending crashed for {}", assigneeEmail, exception); + + return new EmailSendResult( + assigneeEmail, + false, + message + ); + } + } + + private String escapeHtml(String value) { + if (value == null) { + return ""; + } + + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} \ No newline at end of file From 418a8b75b0054dc53eb1e6efbcd6d2f487165988 Mon Sep 17 00:00:00 2001 From: Shadi Date: Wed, 17 Jun 2026 21:21:35 +0200 Subject: [PATCH 2/5] Add integration and service tests for TicketController and TicketService --- .../TicketControllerIntegrationTest.java | 105 ++++++++++ .../service/TicketServiceTest.java | 183 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java create mode 100644 src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java new file mode 100644 index 0000000..b021842 --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java @@ -0,0 +1,105 @@ +package net.hackyourfuture.tickettrackingsystem.controller; + +import net.hackyourfuture.tickettrackingsystem.email.EmailSendResult; +import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Sql( + statements = { + "TRUNCATE TABLE user_ticket, tickets, users, projects RESTART IDENTITY CASCADE", + "INSERT INTO projects (name) VALUES ('Project Alpha')", + "INSERT INTO users (name, email) VALUES ('Alice Doe', 'alice@example.com')", + "INSERT INTO tickets (title, description, project_id, status) VALUES ('Bug login', 'Login button is broken', 1, 'OPEN')", + "INSERT INTO tickets (title, description, project_id, status) VALUES ('Feature export', 'Export tickets to CSV', 1, 'CLOSED')", + "INSERT INTO user_ticket (user_id, ticket_id) VALUES (1, 1)" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD +) +class TicketControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ResendAutomationService resendAutomationService; + + @BeforeEach + void setUp() { + when(resendAutomationService.sendTicketUpdatedEmail( + anyString(), + anyLong(), + anyString(), + anyString(), + anyString(), + anyString() + )).thenAnswer(invocation -> + new EmailSendResult( + invocation.getArgument(0), + true, + "Email sent successfully" + ) + ); + } + + @Test + void getAllTickets_withoutFilters_returnsAllTickets() throws Exception { + mockMvc.perform(get("/tickets")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].title").value("Bug login")) + .andExpect(jsonPath("$[1].title").value("Feature export")); + } + + @Test + void getAllTickets_withTextAndStatus_usesAndLogic() throws Exception { + mockMvc.perform(get("/tickets") + .param("text", "bug") + .param("status", "OPEN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].title").value("Bug login")) + .andExpect(jsonPath("$[0].status").value("OPEN")); + } + + @Test + void updateTicket_returnsTicketAndEmailNotificationResult() throws Exception { + String requestBody = """ + { + "title": "Bug login updated", + "description": "Login button is still broken", + "projectId": 1, + "status": "IN_PROGRESS" + } + """; + + mockMvc.perform(put("/tickets/1") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ticket.id").value(1)) + .andExpect(jsonPath("$.ticket.title").value("Bug login updated")) + .andExpect(jsonPath("$.ticket.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.emailNotifications", hasSize(1))) + .andExpect(jsonPath("$.emailNotifications[0].recipientEmail").value("alice@example.com")) + .andExpect(jsonPath("$.emailNotifications[0].sent").value(true)) + .andExpect(jsonPath("$.emailNotifications[0].message").value("Email sent successfully")); + } +} \ No newline at end of file diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java new file mode 100644 index 0000000..afeed25 --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java @@ -0,0 +1,183 @@ +package net.hackyourfuture.tickettrackingsystem.service; + +import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; +import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; +import net.hackyourfuture.tickettrackingsystem.dto.response.EmailNotificationResponse; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; +import net.hackyourfuture.tickettrackingsystem.email.EmailSendResult; +import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; +import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; +import net.hackyourfuture.tickettrackingsystem.model.Ticket; +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; +import net.hackyourfuture.tickettrackingsystem.model.User; +import net.hackyourfuture.tickettrackingsystem.repository.ProjectRepository; +import net.hackyourfuture.tickettrackingsystem.repository.TicketRepository; +import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TicketServiceTest { + + @Mock + private TicketRepository ticketRepository; + + @Mock + private ProjectRepository projectRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ResendAutomationService resendAutomationService; + + @InjectMocks + private TicketService ticketService; + + @Test + void createTicket_whenProjectDoesNotExist_throwsResourceNotFoundException() { + CreateTicketRequest request = new CreateTicketRequest( + "Bug login", + "Login button is broken", + 99L, + TicketStatus.OPEN + ); + + when(projectRepository.existsById(99L)).thenReturn(false); + + assertThrows( + ResourceNotFoundException.class, + () -> ticketService.createTicket(request) + ); + + verify(ticketRepository, never()).create(any()); + } + + @Test + void updateTicket_sendsEmailsToAllAssigneesAndReturnsEmailResults() { + Ticket existingTicket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + Ticket savedTicket = new Ticket( + 1L, + "Bug login fixed", + "Login button is fixed", + 1L, + TicketStatus.IN_PROGRESS, + existingTicket.creationDate(), + LocalDateTime.now() + ); + + UpdateTicketRequest request = new UpdateTicketRequest( + "Bug login fixed", + "Login button is fixed", + 1L, + TicketStatus.IN_PROGRESS + ); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(existingTicket)); + when(projectRepository.existsById(1L)).thenReturn(true); + when(ticketRepository.update(any(Ticket.class))).thenReturn(savedTicket); + when(ticketRepository.findAssigneeEmailsByTicketId(1L)) + .thenReturn(List.of("alice@example.com", "bob@example.com")); + when(ticketRepository.findAssignedUserIds(1L)) + .thenReturn(List.of(2L, 3L)); + + when(resendAutomationService.sendTicketUpdatedEmail( + anyString(), + eq(1L), + anyString(), + anyString(), + anyString(), + anyString() + )).thenAnswer(invocation -> + new EmailSendResult( + invocation.getArgument(0), + true, + "Email sent successfully" + ) + ); + + TicketUpdateResponse response = ticketService.updateTicket(1L, request); + + assertEquals("Bug login fixed", response.ticket().title()); + assertEquals(TicketStatus.IN_PROGRESS, response.ticket().status()); + assertEquals(2, response.emailNotifications().size()); + assertTrue(response.emailNotifications().get(0).sent()); + assertTrue(response.emailNotifications().get(1).sent()); + + verify(resendAutomationService, times(2)).sendTicketUpdatedEmail( + anyString(), + eq(1L), + anyString(), + anyString(), + eq("System"), + anyString() + ); + } + + @Test + void assignUserToTicket_whenEmailFails_assignmentStillSucceedsAndReturnsFailedEmailResult() { + Ticket ticket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + User user = new User( + 2L, + "Alice Doe", + "alice@example.com" + ); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(ticketRepository.assignmentExists(1L, 2L)).thenReturn(false); + + when(resendAutomationService.sendTicketUpdatedEmail( + eq("alice@example.com"), + eq(1L), + anyString(), + anyString(), + eq("System"), + anyString() + )).thenReturn( + new EmailSendResult( + "alice@example.com", + false, + "Resend failed with status 401" + ) + ); + + EmailNotificationResponse response = + ticketService.assignUserToTicket(1L, 2L); + + assertEquals("alice@example.com", response.recipientEmail()); + assertFalse(response.sent()); + assertEquals("Resend failed with status 401", response.message()); + + verify(ticketRepository).assignUser(1L, 2L); + } +} \ No newline at end of file From a1ec2414388c41343112a31912c41a87bb81b3d9 Mon Sep 17 00:00:00 2001 From: Shadi Date: Wed, 17 Jun 2026 21:22:04 +0200 Subject: [PATCH 3/5] Implement email notifications for ticket updates and user assignments, add filtering to ticket queries, and refactor TicketStatus serialization --- pom.xml | 20 +- .../controller/TicketController.java | 30 +-- .../dto/response/TicketUpdateResponse.java | 9 + .../model/TicketStatus.java | 20 +- .../repository/TicketRepository.java | 69 +++++-- .../service/TicketService.java | 190 ++++++++++++++---- src/main/resources/application.properties | 3 +- src/main/resources/db/schema.sql | 5 +- 8 files changed, 264 insertions(+), 82 deletions(-) create mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java diff --git a/pom.xml b/pom.xml index ff263ee..db872ff 100644 --- a/pom.xml +++ b/pom.xml @@ -57,11 +57,29 @@ lombok provided + + com.resend + resend-java + LATEST + org.springframework.boot spring-boot-starter-web - + + org.springframework.boot + spring-boot-webmvc-test + 4.0.6 + test + + + org.springframework + spring-test + 7.0.7 + test + + + diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java index 5820f9d..c262e16 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java @@ -4,7 +4,9 @@ import lombok.RequiredArgsConstructor; import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; +import net.hackyourfuture.tickettrackingsystem.dto.response.EmailNotificationResponse; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; import net.hackyourfuture.tickettrackingsystem.service.TicketService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -29,8 +31,11 @@ public ResponseEntity createTicket( } @GetMapping - public List getAllTickets() { - return ticketService.getAllTickets(); + public List getAllTickets( + @RequestParam(required = false) String text, + @RequestParam(required = false) String status + ) { + return ticketService.getAllTickets(text, status); } @GetMapping("/{id}") @@ -39,37 +44,34 @@ public TicketResponse getTicketById(@PathVariable Long id) { } @PutMapping("/{id}") - public TicketResponse updateTicket( + public TicketUpdateResponse updateTicket( @PathVariable Long id, @Valid @RequestBody UpdateTicketRequest request ) { return ticketService.updateTicket(id, request); } - @DeleteMapping("/{id}") - public ResponseEntity deleteTicket(@PathVariable Long id) { - ticketService.deleteTicket(id); - return ResponseEntity.noContent().build(); - } @PostMapping("/{ticketId}/assignees/{userId}") - public ResponseEntity assignUserToTicket( + public ResponseEntity assignUserToTicket( @PathVariable Long ticketId, @PathVariable Long userId ) { - ticketService.assignUserToTicket(ticketId, userId); + EmailNotificationResponse response = + ticketService.assignUserToTicket(ticketId, userId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(response); } @DeleteMapping("/{ticketId}/assignees/{userId}") - public ResponseEntity unassignUserFromTicket( + public ResponseEntity unassignUserFromTicket( @PathVariable Long ticketId, @PathVariable Long userId ) { - ticketService.unassignUserFromTicket(ticketId, userId); + EmailNotificationResponse response = + ticketService.unassignUserFromTicket(ticketId, userId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(response); } } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java new file mode 100644 index 0000000..f31acde --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java @@ -0,0 +1,9 @@ +package net.hackyourfuture.tickettrackingsystem.dto.response; + +import java.util.List; + +public record TicketUpdateResponse( + TicketResponse ticket, + List emailNotifications +) { +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java index 93f63a3..3f32881 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java @@ -1,21 +1,7 @@ package net.hackyourfuture.tickettrackingsystem.model; -import com.fasterxml.jackson.annotation.JsonValue; - public enum TicketStatus { - - OPEN("open"), - IN_PROGRESS("in progress"), - CLOSED("closed"); - - private final String value; - - TicketStatus(String value) { - this.value = value; - } - - @JsonValue - public String getValue() { - return value; - } + OPEN, + IN_PROGRESS, + CLOSED } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java index 3ef93fa..d13fb9d 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java @@ -8,6 +8,7 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -47,16 +48,49 @@ INSERT INTO tickets (title, description, project_id, status) ); } - public List findAll() { - String sql = """ - SELECT id, title, description, project_id, status, creation_date, update_date - FROM tickets - ORDER BY creation_date DESC - """; + public List findAll(String text, String status) { + StringBuilder sql = new StringBuilder(""" + SELECT id, title, description, project_id, status, creation_date, update_date + FROM tickets + WHERE 1 = 1 + """); + + List params = new ArrayList<>(); + + String cleanedText = text == null ? null : text.trim(); + String cleanedStatus = status == null ? null : status.trim(); + + if (cleanedText != null && !cleanedText.isBlank()) { + sql.append(""" + AND ( + LOWER(title) LIKE LOWER(?) + OR LOWER(COALESCE(description, '')) LIKE LOWER(?) + ) + """); + + String searchText = "%" + cleanedText + "%"; + params.add(searchText); + params.add(searchText); + } + + if (cleanedStatus != null && !cleanedStatus.isBlank()) { + sql.append(""" + AND status = ?::ticket_status + """); - return jdbcTemplate.query(sql, ticketRowMapper); + params.add(cleanedStatus.toUpperCase()); + } + + sql.append(" ORDER BY id"); + + return jdbcTemplate.query( + sql.toString(), + ticketRowMapper, + params.toArray() + ); } + public Optional findById(Long id) { String sql = """ SELECT id, title, description, project_id, status, creation_date, update_date @@ -95,13 +129,7 @@ public Ticket update(Ticket ticket) { ); } - public boolean deleteById(Long id) { - String sql = "DELETE FROM tickets WHERE id = ?"; - int rowsAffected = jdbcTemplate.update(sql, id); - - return rowsAffected > 0; - } public void assignUser(Long ticketId, Long userId) { String sql = """ @@ -152,4 +180,19 @@ public List findAssignedUserIds(Long ticketId) { return jdbcTemplate.queryForList(sql, Long.class, ticketId); } + + public List findAssigneeEmailsByTicketId(Long ticketId) { + String sql = """ + SELECT u.email + FROM users u + JOIN user_ticket ut ON ut.user_id = u.id + WHERE ut.ticket_id = ? + """; + + return jdbcTemplate.query( + sql, + (rs, rowNum) -> rs.getString("email"), + ticketId + ); + } } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java index ce8ba8c..67dc058 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java @@ -3,15 +3,21 @@ import lombok.RequiredArgsConstructor; import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; +import net.hackyourfuture.tickettrackingsystem.dto.response.EmailNotificationResponse; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; +import net.hackyourfuture.tickettrackingsystem.email.EmailSendResult; +import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; import net.hackyourfuture.tickettrackingsystem.model.Ticket; +import net.hackyourfuture.tickettrackingsystem.model.User; import net.hackyourfuture.tickettrackingsystem.repository.ProjectRepository; import net.hackyourfuture.tickettrackingsystem.repository.TicketRepository; import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Objects; @Service @RequiredArgsConstructor @@ -20,11 +26,13 @@ public class TicketService { private final TicketRepository ticketRepository; private final ProjectRepository projectRepository; private final UserRepository userRepository; + private final ResendAutomationService resendAutomationService; public TicketResponse createTicket(CreateTicketRequest request) { if (!projectRepository.existsById(request.projectId())) { throw new ResourceNotFoundException( - "Project with id " + request.projectId() + " not found"); + "Project with id " + request.projectId() + " not found" + ); } Ticket ticket = new Ticket( @@ -42,8 +50,8 @@ public TicketResponse createTicket(CreateTicketRequest request) { return toTicketResponse(createdTicket); } - public List getAllTickets() { - return ticketRepository.findAll() + public List getAllTickets(String text, String status) { + return ticketRepository.findAll(text, status) .stream() .map(this::toTicketResponse) .toList(); @@ -53,20 +61,25 @@ public TicketResponse getTicketById(Long id) { Ticket ticket = ticketRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException( - "Ticket with id " + id + " not found")); + "Ticket with id " + id + " not found" + ) + ); return toTicketResponse(ticket); } - public TicketResponse updateTicket(Long id, UpdateTicketRequest request) { + public TicketUpdateResponse updateTicket(Long id, UpdateTicketRequest request) { Ticket existingTicket = ticketRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException( - "Ticket with id " + id + " not found")); + "Ticket with id " + id + " not found" + ) + ); if (!projectRepository.existsById(request.projectId())) { throw new ResourceNotFoundException( - "Project with id " + request.projectId() + " not found"); + "Project with id " + request.projectId() + " not found" + ); } Ticket updatedTicket = new Ticket( @@ -81,54 +94,161 @@ public TicketResponse updateTicket(Long id, UpdateTicketRequest request) { Ticket savedTicket = ticketRepository.update(updatedTicket); - return toTicketResponse(savedTicket); + List emailNotifications = + sendTicketUpdateEmails(existingTicket, savedTicket); + + return new TicketUpdateResponse( + toTicketResponse(savedTicket), + emailNotifications + ); } - public void deleteTicket(Long id) { - boolean deleted = ticketRepository.deleteById(id); + public EmailNotificationResponse assignUserToTicket(Long ticketId, Long userId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> + new ResourceNotFoundException( + "Ticket with id " + ticketId + " not found" + ) + ); - if (!deleted) { - throw new ResourceNotFoundException( - "Ticket with id " + id + " not found"); + User user = userRepository.findById(userId) + .orElseThrow(() -> + new ResourceNotFoundException( + "User with id " + userId + " not found" + ) + ); + + if (ticketRepository.assignmentExists(ticketId, userId)) { + throw new IllegalArgumentException( + "User " + userId + " is already assigned to ticket " + ticketId + ); } + + ticketRepository.assignUser(ticketId, userId); + + return sendSingleTicketEmail( + user.email(), + ticket, + "System", + "You have been assigned to this ticket." + ); } - public void assignUserToTicket(Long ticketId, Long userId) { - if (ticketRepository.findById(ticketId).isEmpty()) { - throw new ResourceNotFoundException( - "Ticket with id " + ticketId + " not found"); - } + public EmailNotificationResponse unassignUserFromTicket(Long ticketId, Long userId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> + new ResourceNotFoundException( + "Ticket with id " + ticketId + " not found" + ) + ); - if (userRepository.findById(userId).isEmpty()) { + User user = userRepository.findById(userId) + .orElseThrow(() -> + new ResourceNotFoundException( + "User with id " + userId + " not found" + ) + ); + + boolean removed = ticketRepository.unassignUser(ticketId, userId); + + if (!removed) { throw new ResourceNotFoundException( - "User with id " + userId + " not found"); + "User " + userId + " is not assigned to ticket " + ticketId + ); } - if (ticketRepository.assignmentExists(ticketId, userId)) { - throw new IllegalArgumentException( - "User " + userId + " is already assigned to ticket " + ticketId); + return sendSingleTicketEmail( + user.email(), + ticket, + "System", + "You have been removed from this ticket." + ); + } + + + private List sendTicketUpdateEmails( + Ticket existingTicket, + Ticket savedTicket + ) { + List assigneeEmails = + ticketRepository.findAssigneeEmailsByTicketId(savedTicket.id()); + + if (assigneeEmails.isEmpty()) { + return List.of(); } - ticketRepository.assignUser(ticketId, userId); + String changes = buildChanges(existingTicket, savedTicket); + + return assigneeEmails.stream() + .map(assigneeEmail -> + sendSingleTicketEmail( + assigneeEmail, + savedTicket, + "System", + changes + ) + ) + .toList(); } - public void unassignUserFromTicket(Long ticketId, Long userId) { - if (ticketRepository.findById(ticketId).isEmpty()) { - throw new ResourceNotFoundException( - "Ticket with id " + ticketId + " not found"); + private EmailNotificationResponse sendSingleTicketEmail( + String assigneeEmail, + Ticket ticket, + String updatedBy, + String changes + ) { + EmailSendResult result = resendAutomationService.sendTicketUpdatedEmail( + assigneeEmail, + ticket.id(), + ticket.title(), + String.valueOf(ticket.status()), + updatedBy, + changes + ); + + return new EmailNotificationResponse( + result.recipientEmail(), + result.sent(), + result.message() + ); + } + + private String buildChanges(Ticket oldTicket, Ticket newTicket) { + StringBuilder changes = new StringBuilder(); + + if (!Objects.equals(oldTicket.title(), newTicket.title())) { + changes.append("Title changed from \"") + .append(oldTicket.title()) + .append("\" to \"") + .append(newTicket.title()) + .append("\"\n"); } - if (userRepository.findById(userId).isEmpty()) { - throw new ResourceNotFoundException( - "User with id " + userId + " not found"); + if (!Objects.equals(oldTicket.description(), newTicket.description())) { + changes.append("Description was updated\n"); } - boolean removed = ticketRepository.unassignUser(ticketId, userId); + if (!Objects.equals(oldTicket.projectId(), newTicket.projectId())) { + changes.append("Project changed from ") + .append(oldTicket.projectId()) + .append(" to ") + .append(newTicket.projectId()) + .append("\n"); + } - if (!removed) { - throw new ResourceNotFoundException( - "User " + userId + " is not assigned to ticket " + ticketId); + if (!Objects.equals(oldTicket.status(), newTicket.status())) { + changes.append("Status changed from ") + .append(oldTicket.status()) + .append(" to ") + .append(newTicket.status()) + .append("\n"); } + + if (changes.isEmpty()) { + return "Ticket was updated, but no tracked fields changed."; + } + + return changes.toString(); } private TicketResponse toTicketResponse(Ticket ticket) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3145487..000b549 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,4 +4,5 @@ spring.datasource.url=jdbc:postgresql://localhost:5432/ticket_tracker spring.datasource.username=ticket_user spring.datasource.password=ticket_password spring.datasource.driver-class-name=org.postgresql.Driver - +resend.api-key=${RESEND_API_KEY} +resend.from-email=Ticket Tracking \ No newline at end of file diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index a0c4a2f..b872de0 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -48,4 +48,7 @@ CREATE INDEX idx_tickets_project_id ON tickets(project_id); CREATE INDEX idx_user_ticket_user_id - ON user_ticket(user_id); \ No newline at end of file + ON user_ticket(user_id); + +CREATE INDEX idx_user_ticket_ticket_id + ON user_ticket(ticket_id); \ No newline at end of file From 29e49e255bf49875cce5e2c50b70fbd569c3bdcf Mon Sep 17 00:00:00 2001 From: Shadi Date: Wed, 17 Jun 2026 23:58:58 +0200 Subject: [PATCH 4/5] Remove unused email-related classes and DTOs, add `.env.example` for configuration, and introduce new configuration, converters, and integration tests for streamlined functionality and maintainability. --- .env.example | 7 ++ .../config/EmailConfig.java | 18 +++ .../config/StringToTicketStatusConverter.java | 18 +++ .../response/EmailNotificationResponse.java | 8 -- .../email/EmailSendResult.java | 8 -- .../email/EmailSendingException.java | 12 -- .../ProjectControllerIntegrationTest.java | 48 ++++++++ .../UserControllerIntegrationTest.java | 93 ++++++++++++++ .../service/UserServiceTest.java | 116 ++++++++++++++++++ 9 files changed, 300 insertions(+), 28 deletions(-) create mode 100644 .env.example create mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/config/EmailConfig.java create mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/config/StringToTicketStatusConverter.java delete mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java delete mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java delete mode 100644 src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java create mode 100644 src/test/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectControllerIntegrationTest.java create mode 100644 src/test/java/net/hackyourfuture/tickettrackingsystem/controller/UserControllerIntegrationTest.java create mode 100644 src/test/java/net/hackyourfuture/tickettrackingsystem/service/UserServiceTest.java diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..522c44f --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Database connection +DB_URL=jdbc:postgresql://localhost:5432/ticket_tracker +DB_USER=ticket_user +DB_PASSWORD=change_me + +# Resend email API +RESEND_API_KEY=your_resend_api_key_here diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/config/EmailConfig.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/EmailConfig.java new file mode 100644 index 0000000..88dde97 --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/EmailConfig.java @@ -0,0 +1,18 @@ +package net.hackyourfuture.tickettrackingsystem.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class EmailConfig { + + @Bean + public HttpClient resendHttpClient() { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/config/StringToTicketStatusConverter.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/StringToTicketStatusConverter.java new file mode 100644 index 0000000..e76f8d2 --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/StringToTicketStatusConverter.java @@ -0,0 +1,18 @@ +package net.hackyourfuture.tickettrackingsystem.config; + +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class StringToTicketStatusConverter implements Converter { + + @Override + public TicketStatus convert(String source) { + if (source == null || source.isBlank()) { + return null; + } + + return TicketStatus.fromValue(source); + } +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java deleted file mode 100644 index 83d059d..0000000 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/EmailNotificationResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package net.hackyourfuture.tickettrackingsystem.dto.response; - -public record EmailNotificationResponse( - String recipientEmail, - boolean sent, - String message -) { -} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java deleted file mode 100644 index 55fa6ad..0000000 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendResult.java +++ /dev/null @@ -1,8 +0,0 @@ -package net.hackyourfuture.tickettrackingsystem.email; - -public record EmailSendResult( - String recipientEmail, - boolean sent, - String message -) { -} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java deleted file mode 100644 index 3bed86b..0000000 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/EmailSendingException.java +++ /dev/null @@ -1,12 +0,0 @@ -package net.hackyourfuture.tickettrackingsystem.email; - -public class EmailSendingException extends RuntimeException { - - public EmailSendingException(String message) { - super(message); - } - - public EmailSendingException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectControllerIntegrationTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectControllerIntegrationTest.java new file mode 100644 index 0000000..628c026 --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectControllerIntegrationTest.java @@ -0,0 +1,48 @@ +package net.hackyourfuture.tickettrackingsystem.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Sql( + statements = { + "TRUNCATE TABLE user_ticket, tickets, users, projects RESTART IDENTITY CASCADE", + "INSERT INTO projects (name) VALUES ('Project Alpha')", + "INSERT INTO projects (name) VALUES ('Project Beta')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A1', 1, 'OPEN')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A2', 1, 'OPEN')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A3', 1, 'IN_PROGRESS')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A4', 1, 'CLOSED')", + "INSERT INTO tickets (title, project_id, status) VALUES ('B1', 2, 'CLOSED')" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD +) +class ProjectControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getAllProjects_returnsProjectsWithStatusCounts() throws Exception { + mockMvc.perform(get("/api/v1/projects")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].name").value("Project Alpha")) + .andExpect(jsonPath("$[0].openTickets").value(2)) + .andExpect(jsonPath("$[0].inProgressTickets").value(1)) + .andExpect(jsonPath("$[0].closedTickets").value(1)) + .andExpect(jsonPath("$[1].name").value("Project Beta")) + .andExpect(jsonPath("$[1].openTickets").value(0)) + .andExpect(jsonPath("$[1].inProgressTickets").value(0)) + .andExpect(jsonPath("$[1].closedTickets").value(1)); + } +} diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/UserControllerIntegrationTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/UserControllerIntegrationTest.java new file mode 100644 index 0000000..956750f --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/UserControllerIntegrationTest.java @@ -0,0 +1,93 @@ +package net.hackyourfuture.tickettrackingsystem.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Sql( + statements = { + "TRUNCATE TABLE user_ticket, tickets, users, projects RESTART IDENTITY CASCADE", + "INSERT INTO users (name, email) VALUES ('Alice Doe', 'alice@example.com')", + "INSERT INTO users (name, email) VALUES ('Bob Smith', 'bob@example.com')" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD +) +class UserControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getAllUsers_returnsBothSeededUsers() throws Exception { + mockMvc.perform(get("/api/v1/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].email").value("alice@example.com")) + .andExpect(jsonPath("$[1].email").value("bob@example.com")); + } + + @Test + void getUserById_whenUserMissing_returnsNotFound() throws Exception { + mockMvc.perform(get("/api/v1/users/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void createUser_withDuplicateEmail_returnsConflict() throws Exception { + String body = """ + { "name": "Another Alice", "email": "alice@example.com" } + """; + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value(409)); + } + + @Test + void createUser_withInvalidEmail_returnsBadRequestWithFieldError() throws Exception { + String body = """ + { "name": "Eve", "email": "not-an-email" } + """; + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.validationErrors.email").exists()); + } + + @Test + void createUser_withValidPayload_returnsCreatedUser() throws Exception { + String body = """ + { "name": "Carol", "email": "carol@example.com" } + """; + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").value("Carol")) + .andExpect(jsonPath("$.email").value("carol@example.com")); + } + + @Test + void deleteUser_whenUserMissing_returnsNotFound() throws Exception { + mockMvc.perform(delete("/api/v1/users/999")) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/service/UserServiceTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/UserServiceTest.java new file mode 100644 index 0000000..ae41013 --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/UserServiceTest.java @@ -0,0 +1,116 @@ +package net.hackyourfuture.tickettrackingsystem.service; + +import net.hackyourfuture.tickettrackingsystem.dto.request.CreateUserRequest; +import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateUserRequest; +import net.hackyourfuture.tickettrackingsystem.dto.response.UserResponse; +import net.hackyourfuture.tickettrackingsystem.exception.DuplicateEmailException; +import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; +import net.hackyourfuture.tickettrackingsystem.model.User; +import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserService userService; + + @Test + void createUser_whenEmailAlreadyExists_throwsDuplicateEmailException() { + CreateUserRequest request = new CreateUserRequest( + "Alice Doe", + "alice@example.com" + ); + + when(userRepository.findByEmail("alice@example.com")) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + + assertThrows( + DuplicateEmailException.class, + () -> userService.createUser(request) + ); + + verify(userRepository, never()).create(any()); + } + + @Test + void createUser_whenEmailIsFree_persistsAndReturnsResponse() { + CreateUserRequest request = new CreateUserRequest( + "Carol", + "carol@example.com" + ); + + when(userRepository.findByEmail("carol@example.com")) + .thenReturn(Optional.empty()); + when(userRepository.create(any(User.class))) + .thenReturn(new User(7L, "Carol", "carol@example.com")); + + UserResponse response = userService.createUser(request); + + assertEquals(7L, response.id()); + assertEquals("Carol", response.name()); + assertEquals("carol@example.com", response.email()); + } + + @Test + void updateUser_whenEmailIsTakenByAnotherUser_throwsDuplicateEmailException() { + UpdateUserRequest request = new UpdateUserRequest( + "Alice Renamed", + "bob@example.com" + ); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + when(userRepository.findByEmail("bob@example.com")) + .thenReturn(Optional.of(new User(2L, "Bob Smith", "bob@example.com"))); + + assertThrows( + DuplicateEmailException.class, + () -> userService.updateUser(1L, request) + ); + + verify(userRepository, never()).update(any()); + } + + @Test + void updateUser_whenEmailUnchanged_keepsGoingAndReturnsUpdatedUser() { + UpdateUserRequest request = new UpdateUserRequest( + "Alice Renamed", + "alice@example.com" + ); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + when(userRepository.findByEmail("alice@example.com")) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + when(userRepository.update(any(User.class))) + .thenReturn(new User(1L, "Alice Renamed", "alice@example.com")); + + UserResponse response = userService.updateUser(1L, request); + + assertEquals("Alice Renamed", response.name()); + } + + @Test + void deleteUser_whenUserMissing_throwsResourceNotFoundException() { + when(userRepository.deleteById(99L)).thenReturn(false); + + assertThrows( + ResourceNotFoundException.class, + () -> userService.deleteUser(99L) + ); + } +} From 77de942b9457b705a38f00d7249f2e367199778e Mon Sep 17 00:00:00 2001 From: Shadi Date: Thu, 18 Jun 2026 00:04:26 +0200 Subject: [PATCH 5/5] Add comprehensive API reference documentation and refactor ticket-related email notification logic. --- .github/workflows/ci.yml | 23 +- .gitignore | 5 + API.md | 592 ++++++++++++++++++ pom.xml | 16 +- .../TicketTrackingSystemApplication.java | 4 +- .../controller/ProjectController.java | 2 +- .../controller/TicketController.java | 14 +- .../controller/UserController.java | 2 +- .../dto/request/CreateTicketRequest.java | 17 +- .../dto/request/CreateUserRequest.java | 8 +- .../dto/request/UpdateTicketRequest.java | 17 +- .../dto/request/UpdateUserRequest.java | 6 +- .../dto/response/TicketUpdateResponse.java | 4 +- .../email/ResendAutomationService.java | 59 +- .../exception/GlobalExceptionHandler.java | 37 ++ .../model/TicketStatus.java | 42 +- .../repository/TicketRepository.java | 68 +- .../service/ProjectService.java | 4 +- .../service/TicketService.java | 131 ++-- .../service/UserService.java | 6 + src/main/resources/application.properties | 5 +- src/main/resources/{db => }/schema.sql | 47 +- .../TicketControllerIntegrationTest.java | 61 +- .../service/TicketServiceTest.java | 128 ++-- 24 files changed, 1034 insertions(+), 264 deletions(-) create mode 100644 API.md rename src/main/resources/{db => }/schema.sql (56%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68db75a..71688c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,27 @@ jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ticket_user + POSTGRES_PASSWORD: ticket_password + POSTGRES_DB: ticket_tracker + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U ticket_user -d ticket_tracker" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + RESEND_API_KEY: dummy-ci-key + DB_URL: jdbc:postgresql://localhost:5432/ticket_tracker + DB_USER: ticket_user + DB_PASSWORD: ticket_password + steps: - uses: actions/checkout@v4 @@ -19,4 +40,4 @@ jobs: java-version: '25' cache: maven - - run: mvn clean verify \ No newline at end of file + - run: mvn clean verify diff --git a/.gitignore b/.gitignore index 1ff575d..d3843b0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ build/ ### System Files ### .DS_Store Thumbs.db + +### Environment ### +.env +.env.local +!.env.example diff --git a/API.md b/API.md new file mode 100644 index 0000000..29f1a4b --- /dev/null +++ b/API.md @@ -0,0 +1,592 @@ +# Ticket Tracking System — API & Project Reference + +A complete reference document covering the project summary, architecture, data model, and every REST endpoint exposed by the Ticket Tracking System. + +--- + +## Table of Contents + +1. [Project Summary](#1-project-summary) +2. [Tech Stack](#2-tech-stack) +3. [Architecture Overview](#3-architecture-overview) +4. [Data Model](#4-data-model) +5. [Configuration & Setup](#5-configuration--setup) +6. [API Conventions](#6-api-conventions) +7. [Users API](#7-users-api) +8. [Projects API](#8-projects-api) +9. [Tickets API](#9-tickets-api) +10. [Email Notifications](#10-email-notifications) +11. [Error Handling](#11-error-handling) +12. [Testing](#12-testing) + +--- + +## 1. Project Summary + +The **Ticket Tracking System** is a REST API for managing software-development style work items. It supports: + +- **Users** — people who can be assigned to tickets (CRUD). +- **Projects** — containers for tickets, exposed read-only with per-status ticket counts. +- **Tickets** — work items with a title, description, status, project, and many-to-many assignees. Supports text + status filtering. +- **Email notifications** — whenever a ticket is updated or a user is assigned/unassigned, the assignee receives an HTML email via the Resend API. Dispatch is asynchronous and fire-and-forget; failures are logged, never surfaced to callers. + +The codebase is intentionally small, layered (controller → service → repository), and uses `JdbcTemplate` directly instead of JPA — every SQL statement is visible in the repository classes. + +--- + +## 2. Tech Stack + +| Layer | Choice | +|------------------|----------------------------------------------------------| +| Language | Java 25 | +| Framework | Spring Boot 4.1.0 | +| Web | `spring-boot-starter-web` (Spring MVC) | +| Persistence | `spring-boot-starter-jdbc` (JdbcTemplate) | +| Validation | `spring-boot-starter-validation` (Jakarta Bean Validation) | +| Async | `@EnableAsync` for email dispatch | +| Database | PostgreSQL 16+ (with a custom `ticket_status` ENUM type) | +| Email Provider | Resend HTTP API | +| HTTP Client | `java.net.http.HttpClient` (JDK built-in) | +| Boilerplate | Lombok (`@RequiredArgsConstructor`) | +| Tests | JUnit 5 + Spring Boot Test (integration + service) | +| Build | Maven (`./mvnw`) | +| CI | GitHub Actions (runs `./mvnw verify` against Postgres) | + +--- + +## 3. Architecture Overview + +``` +HTTP request + │ + ▼ +┌──────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ +│ Controller │──▶ │ Service │──▶ │ Repository │──▶ │ PostgreSQL │ +└──────────────┘ └─────────────┘ └──────────────┘ └────────────┘ + │ + │ (@Async, fire-and-forget) + ▼ + ┌─────────────────────────┐ ┌────────────────┐ + │ ResendAutomationService │──▶ │ Resend API │ + └─────────────────────────┘ └────────────────┘ +``` + +### Layers + +- **`controller/`** — Pure HTTP shape: route mapping, request validation (`@Valid`), status codes. No business logic. +- **`service/`** — Business logic. All mutating methods are `@Transactional`; reads are `@Transactional(readOnly = true)`. +- **`repository/`** — Plain JDBC via `JdbcTemplate`. Native SQL, parameter-bound. No JPA, no entity manager. +- **`email/`** — `ResendAutomationService` sends HTML emails asynchronously, uses the JDK `HttpClient`, escapes HTML, logs failures. +- **`exception/`** — `GlobalExceptionHandler` (`@RestControllerAdvice`) maps domain and framework exceptions to a single `ErrorResponse` JSON shape. +- **`dto/`** — Java `record` types for both `request/` and `response/` payloads. +- **`model/`** — Domain records (`User`, `Ticket`) and the `TicketStatus` enum. +- **`config/`** — `EmailConfig` (HttpClient bean) and `StringToTicketStatusConverter` (query parameter binding). + +### Key Design Choices + +- **Records everywhere** — DTOs and models are immutable records. +- **No JPA** — explicit SQL keeps the layer thin and easy to reason about. +- **Postgres ENUM for status** — the `tickets.status` column is a `ticket_status` ENUM (`OPEN`, `IN_PROGRESS`, `CLOSED`). The API exposes them lowercased / space-separated (`open`, `in progress`, `closed`) via `@JsonValue` / `@JsonCreator` on the enum. +- **Race-free assignment** — `INSERT ... ON CONFLICT DO NOTHING` on the junction table; the affected-rows count signals whether the row was actually inserted. +- **Async email** — `@EnableAsync` on the application class; `@Async` on `sendTicketUpdatedEmail`. Email failures never affect the HTTP response. + +--- + +## 4. Data Model + +### Tables + +#### `users` +| Column | Type | Constraints | +|--------|----------------|----------------------------------------------| +| id | `bigserial` | PRIMARY KEY | +| name | `varchar(255)` | NOT NULL, `char_length(name) >= 3` | +| email | `varchar(255)` | NOT NULL, UNIQUE | + +#### `projects` +| Column | Type | Constraints | +|--------|----------------|----------------------------------------------| +| id | `bigserial` | PRIMARY KEY | +| name | `varchar(255)` | NOT NULL, `char_length(name) >= 3` | + +#### `tickets` +| Column | Type | Constraints | +|---------------|------------------|--------------------------------------------------------| +| id | `bigserial` | PRIMARY KEY | +| title | `varchar(255)` | NOT NULL | +| description | `text` | nullable | +| project_id | `bigint` | NOT NULL, FK → `projects(id)` | +| status | `ticket_status` | NOT NULL — Postgres ENUM (`OPEN`, `IN_PROGRESS`, `CLOSED`) | +| creation_date | `timestamp` | NOT NULL, DEFAULT `CURRENT_TIMESTAMP` | +| update_date | `timestamp` | nullable, set on every `UPDATE` | + +Indexes: `idx_tickets_status`, `idx_tickets_project_id`. + +#### `user_ticket` (junction) +| Column | Type | Constraints | +|-----------|----------|------------------------------------------------------| +| user_id | `bigint` | NOT NULL, FK → `users(id)` ON DELETE CASCADE | +| ticket_id | `bigint` | NOT NULL, FK → `tickets(id)` ON DELETE CASCADE | + +Composite primary key: `(user_id, ticket_id)`. Index: `idx_user_ticket_ticket_id`. + +### Entity Relationships + +``` +users 1 ─── * user_ticket * ─── 1 tickets * ─── 1 projects +``` + +A user can be assigned to many tickets and a ticket can have many assignees. + +--- + +## 5. Configuration & Setup + +### Prerequisites + +- Java 25 (Temurin recommended) +- PostgreSQL 16+ +- A Resend account + API key + +### Database + +```bash +psql -U postgres -c "CREATE USER ticket_user WITH PASSWORD 'ticket_password';" +psql -U postgres -c "CREATE DATABASE ticket_tracker OWNER ticket_user;" +psql -U ticket_user -d ticket_tracker -f src/main/resources/schema.sql +``` + +### Environment Variables + +Copy `.env.example` to `.env` and fill in: + +| Variable | Default | Purpose | +|----------------------|----------------------------------------------------------|------------------------------------| +| `DB_USER` | _(none)_ | Postgres username | +| `DB_PASSWORD` | _(none)_ | Postgres password | +| `DB_URL` | `jdbc:postgresql://localhost:5432/ticket_tracker` | JDBC URL | +| `RESEND_API_KEY` | _(none)_ | Resend API key | +| `RESEND_FROM_EMAIL` | `Ticket Tracking ` | "From" address used in outgoing mail | + +### Running the App + +```bash +export $(grep -v '^#' .env | xargs) +./mvnw spring-boot:run +``` + +The API listens on **`http://localhost:8080`**. + +--- + +## 6. API Conventions + +- **Base path:** `/api/v1` +- **Content type:** `application/json` for both requests and responses. +- **IDs:** all resource identifiers are `Long` (64-bit integer). +- **Dates:** ISO-8601 `LocalDateTime`, e.g. `2026-06-17T12:00:00`. +- **Validation:** invalid request bodies return `400 Bad Request` with a `validationErrors` map. +- **Ticket status (wire format):** + - `"open"` ↔ enum `OPEN` + - `"in progress"` ↔ enum `IN_PROGRESS` + - `"closed"` ↔ enum `CLOSED` +- Query parameters for `status` accept lowercased, space-separated, or underscored forms (case-insensitive). E.g. `open`, `IN_PROGRESS`, `in-progress`. + +--- + +## 7. Users API + +### 7.1 List users + +``` +GET /api/v1/users +``` + +**Response — 200 OK** +```json +[ + { "id": 1, "name": "Alice", "email": "alice@example.com" }, + { "id": 2, "name": "Bob", "email": "bob@example.com" } +] +``` + +--- + +### 7.2 Get user by ID + +``` +GET /api/v1/users/{id} +``` + +**Response — 200 OK** +```json +{ "id": 1, "name": "Alice", "email": "alice@example.com" } +``` + +**Errors** +- `404 Not Found` — user does not exist. + +--- + +### 7.3 Create user + +``` +POST /api/v1/users +``` + +**Request body** +```json +{ "name": "Alice", "email": "alice@example.com" } +``` + +**Validation** +- `name`: not blank, 3–255 chars. +- `email`: not blank, valid email format, max 255 chars. + +**Response — 201 Created** +```json +{ "id": 1, "name": "Alice", "email": "alice@example.com" } +``` + +**Errors** +- `400 Bad Request` — validation failure. +- `409 Conflict` — email already in use. + +--- + +### 7.4 Update user + +``` +PUT /api/v1/users/{id} +``` + +Same body and validation as Create. + +**Response — 200 OK** +```json +{ "id": 1, "name": "Alice Doe", "email": "alice.doe@example.com" } +``` + +**Errors** +- `400 Bad Request` — validation failure. +- `404 Not Found` — user does not exist. +- `409 Conflict` — email belongs to another user. + +--- + +### 7.5 Delete user + +``` +DELETE /api/v1/users/{id} +``` + +**Response — 204 No Content** + +**Errors** +- `404 Not Found` — user does not exist. + +> Deleting a user automatically removes their rows in `user_ticket` (cascade). + +--- + +## 8. Projects API + +### 8.1 List projects with ticket counts + +``` +GET /api/v1/projects +``` + +Returns every project alongside ticket counts grouped by status. Projects with no tickets show zero counts. + +**Response — 200 OK** +```json +[ + { + "id": 1, + "name": "Project Alpha", + "openTickets": 10, + "inProgressTickets": 5, + "closedTickets": 30 + }, + { + "id": 2, + "name": "Project Beta", + "openTickets": 0, + "inProgressTickets": 0, + "closedTickets": 0 + } +] +``` + +> Projects are read-only via the API. They are inserted directly into the database (e.g. via seed scripts). + +--- + +## 9. Tickets API + +### 9.1 List / search tickets + +``` +GET /api/v1/tickets?text={text}&status={status} +``` + +| Query | Type | Required | Description | +|-----------|--------|----------|----------------------------------------------------------------------------------------------| +| `text` | string | no | Case-insensitive substring search over `title` and `description`. | +| `status` | enum | no | One of `open`, `in progress`, `closed` (also accepts `in-progress`, `IN_PROGRESS`, etc.). | + +Both filters combine with **AND**. + +**Response — 200 OK** +```json +[ + { + "id": 1, + "title": "Login button broken", + "description": "Clicking does nothing on Firefox", + "projectId": 1, + "status": "open", + "assignedUserIds": [1, 2], + "creationDate": "2026-06-15T09:00:00", + "updateDate": null + } +] +``` + +**Errors** +- `400 Bad Request` — unknown status value. + +--- + +### 9.2 Get ticket by ID + +``` +GET /api/v1/tickets/{id} +``` + +**Response — 200 OK** (same `TicketResponse` shape as above) + +**Errors** +- `404 Not Found` — ticket does not exist. + +--- + +### 9.3 Create ticket + +``` +POST /api/v1/tickets +``` + +**Request body** +```json +{ + "title": "Login button is broken", + "description": "Clicking does nothing on Firefox", + "projectId": 1, + "status": "open" +} +``` + +**Validation** +- `title`: not blank, max 255 chars. +- `description`: nullable, max 5000 chars. +- `projectId`: required. +- `status`: required, one of the allowed values. + +**Response — 201 Created** +```json +{ + "id": 42, + "title": "Login button is broken", + "description": "Clicking does nothing on Firefox", + "projectId": 1, + "status": "open", + "assignedUserIds": [], + "creationDate": "2026-06-17T12:00:00", + "updateDate": null +} +``` + +**Errors** +- `400 Bad Request` — validation failure. +- `404 Not Found` — `projectId` does not exist. + +--- + +### 9.4 Update ticket + +``` +PUT /api/v1/tickets/{id} +``` + +Updates `title`, `description`, `projectId`, and `status`. Sends an email to **every** assignee describing the diff between the previous and the new ticket. + +**Request body** — same shape and validation as Create. + +**Response — 200 OK** +```json +{ + "ticket": { + "id": 42, + "title": "Login button is broken on Firefox 130", + "description": "Clicking does nothing on Firefox", + "projectId": 1, + "status": "in progress", + "assignedUserIds": [1, 2], + "creationDate": "2026-06-17T12:00:00", + "updateDate": "2026-06-17T13:30:00" + }, + "emailNotificationsDispatched": true +} +``` + +`emailNotificationsDispatched` is `true` when at least one assignee email was queued for delivery. The actual HTTP call to Resend happens asynchronously — any failure is logged and does **not** affect the response. + +**Errors** +- `400 Bad Request` — validation failure. +- `404 Not Found` — ticket or `projectId` does not exist. + +--- + +### 9.5 Assign user to ticket + +``` +POST /api/v1/tickets/{ticketId}/assignees/{userId} +``` + +Adds a row to `user_ticket`. Sends the user an email saying they have been assigned. + +**Response — 200 OK** — the updated `TicketResponse`. + +**Errors** +- `400 Bad Request` — user is already assigned to this ticket. +- `404 Not Found` — ticket or user does not exist. + +--- + +### 9.6 Unassign user from ticket + +``` +DELETE /api/v1/tickets/{ticketId}/assignees/{userId} +``` + +Removes the assignment. Sends the user an email saying they have been removed. + +**Response — 200 OK** — the updated `TicketResponse`. + +**Errors** +- `404 Not Found` — ticket or user does not exist, or the user was not assigned to this ticket. + +--- + +## 10. Email Notifications + +Implemented by `ResendAutomationService`: + +- Triggered by **three** flows: + 1. `PUT /api/v1/tickets/{id}` — every assignee receives an email with the diff. + 2. `POST /api/v1/tickets/{ticketId}/assignees/{userId}` — the newly-assigned user is notified. + 3. `DELETE /api/v1/tickets/{ticketId}/assignees/{userId}` — the unassigned user is notified. + +- Email body is an HTML template (escaped for safety) containing the ticket ID, title, status, who triggered the update (currently always `"System"`), and a human-readable change summary. + +- Transport: HTTP POST to `https://api.resend.com/emails` with a 10-second timeout and a 5-second connect timeout. + +- **Failure handling:** non-2xx responses or exceptions are logged at WARN/ERROR. They never propagate to the controller. + +--- + +## 11. Error Handling + +All errors share the same envelope (`ErrorResponse`): + +```json +{ + "timestamp": "2026-06-17T12:00:00", + "status": 400, + "error": "Bad Request", + "message": "Validation failed", + "path": "/api/v1/users", + "validationErrors": { + "email": "must be a well-formed email address" + } +} +``` + +`validationErrors` is only populated for `MethodArgumentNotValidException` (Bean Validation failures); it is `null` otherwise. + +### Status-code mapping (`GlobalExceptionHandler`) + +| Exception | HTTP Status | Notes | +|----------------------------------------|---------------------------|--------------------------------------------------------| +| `ResourceNotFoundException` | `404 Not Found` | Unknown user/ticket/project ID. | +| `DuplicateEmailException` | `409 Conflict` | Email already exists. | +| `IllegalArgumentException` | `400 Bad Request` | E.g. assigning an already-assigned user, bad status. | +| `MethodArgumentNotValidException` | `400 Bad Request` | Bean Validation errors → `validationErrors` map. | +| `MethodArgumentTypeMismatchException` | `400 Bad Request` | Query/path parameter cannot be converted. | +| `HttpMessageNotReadableException` | `400 Bad Request` | Malformed JSON in the request body. | +| `DataIntegrityViolationException` | `409 Conflict` | DB constraint violation; cause is logged at WARN. | +| `Exception` (fallback) | `500 Internal Server Error` | Unhandled exceptions; full stack is logged. | + +--- + +## 12. Testing + +```bash +./mvnw verify +``` + +The integration tests require a running Postgres with the schema applied. CI runs the same command against a containerised Postgres on every push to `main` and every PR (see `.github/workflows/ci.yml`). + +### Test inventory + +- `TicketTrackingSystemApplicationTests` — context-loads sanity check. +- `controller/UserControllerIntegrationTest` — full HTTP slice for users. +- `controller/ProjectControllerIntegrationTest` — full HTTP slice for projects. +- `controller/TicketControllerIntegrationTest` — full HTTP slice for tickets, including assignment flows. +- `service/UserServiceTest` — unit tests for `UserService` against a mocked repository. +- `service/TicketServiceTest` — unit tests for `TicketService` against mocked repositories + `ResendAutomationService` (verifies emails are dispatched on update/assign/unassign). + +--- + +## Appendix A — Endpoint Cheat Sheet + +| Method | Path | Status (success) | Auth | Body | Returns | +|--------|-----------------------------------------------------|------------------|------|------|--------------------------| +| GET | `/api/v1/users` | 200 | — | — | `UserResponse[]` | +| GET | `/api/v1/users/{id}` | 200 | — | — | `UserResponse` | +| POST | `/api/v1/users` | 201 | — | yes | `UserResponse` | +| PUT | `/api/v1/users/{id}` | 200 | — | yes | `UserResponse` | +| DELETE | `/api/v1/users/{id}` | 204 | — | — | — | +| GET | `/api/v1/projects` | 200 | — | — | `ProjectSummaryResponse[]` | +| GET | `/api/v1/tickets?text=&status=` | 200 | — | — | `TicketResponse[]` | +| GET | `/api/v1/tickets/{id}` | 200 | — | — | `TicketResponse` | +| POST | `/api/v1/tickets` | 201 | — | yes | `TicketResponse` | +| PUT | `/api/v1/tickets/{id}` | 200 | — | yes | `TicketUpdateResponse` | +| POST | `/api/v1/tickets/{ticketId}/assignees/{userId}` | 200 | — | — | `TicketResponse` | +| DELETE | `/api/v1/tickets/{ticketId}/assignees/{userId}` | 200 | — | — | `TicketResponse` | + +> The API is currently unauthenticated — there is no auth layer in the codebase. Add one before exposing this service publicly. + +## Appendix B — DTO Reference + +### Requests + +```text +CreateUserRequest { name, email } +UpdateUserRequest { name, email } +CreateTicketRequest { title, description?, projectId, status } +UpdateTicketRequest { title, description?, projectId, status } +``` + +### Responses + +```text +UserResponse { id, name, email } +ProjectSummaryResponse { id, name, openTickets, inProgressTickets, closedTickets } +TicketResponse { id, title, description, projectId, status, + assignedUserIds[], creationDate, updateDate } +TicketUpdateResponse { ticket: TicketResponse, emailNotificationsDispatched } +ErrorResponse { timestamp, status, error, message, path, validationErrors? } +``` diff --git a/pom.xml b/pom.xml index db872ff..759eae7 100644 --- a/pom.xml +++ b/pom.xml @@ -58,24 +58,12 @@ provided - com.resend - resend-java - LATEST + org.springframework.boot + spring-boot-starter-web - - org.springframework.boot - spring-boot-starter-web - org.springframework.boot spring-boot-webmvc-test - 4.0.6 - test - - - org.springframework - spring-test - 7.0.7 test diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java index b70982c..aca0768 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java @@ -2,11 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class TicketTrackingSystemApplication { - static void main(String[] args) { + public static void main(String[] args) { SpringApplication.run(TicketTrackingSystemApplication.class, args); } diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java index bf29d58..d61f2be 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java @@ -11,7 +11,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/projects") +@RequestMapping("/api/v1/projects") public class ProjectController { private final ProjectService projectService; diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java index c262e16..52bf72d 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java @@ -4,9 +4,9 @@ import lombok.RequiredArgsConstructor; import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; -import net.hackyourfuture.tickettrackingsystem.dto.response.EmailNotificationResponse; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; import net.hackyourfuture.tickettrackingsystem.service.TicketService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -16,7 +16,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/tickets") +@RequestMapping("/api/v1/tickets") public class TicketController { private final TicketService ticketService; @@ -33,7 +33,7 @@ public ResponseEntity createTicket( @GetMapping public List getAllTickets( @RequestParam(required = false) String text, - @RequestParam(required = false) String status + @RequestParam(required = false) TicketStatus status ) { return ticketService.getAllTickets(text, status); } @@ -54,22 +54,22 @@ public TicketUpdateResponse updateTicket( @PostMapping("/{ticketId}/assignees/{userId}") - public ResponseEntity assignUserToTicket( + public ResponseEntity assignUserToTicket( @PathVariable Long ticketId, @PathVariable Long userId ) { - EmailNotificationResponse response = + TicketResponse response = ticketService.assignUserToTicket(ticketId, userId); return ResponseEntity.ok(response); } @DeleteMapping("/{ticketId}/assignees/{userId}") - public ResponseEntity unassignUserFromTicket( + public ResponseEntity unassignUserFromTicket( @PathVariable Long ticketId, @PathVariable Long userId ) { - EmailNotificationResponse response = + TicketResponse response = ticketService.unassignUserFromTicket(ticketId, userId); return ResponseEntity.ok(response); diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java index f414dfa..c6b8053 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java @@ -13,7 +13,7 @@ import java.util.List; @RestController -@RequestMapping("/users") +@RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserController { diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java index 888db61..1f65691 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java @@ -2,12 +2,21 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; public record CreateTicketRequest( - @NotBlank String title, + @NotBlank + @Size(max = 255) + String title, + + @Size(max = 5000) String description, - @NotNull Long projectId, - @NotNull TicketStatus status + + @NotNull + Long projectId, + + @NotNull + TicketStatus status ) { -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java index 50a64d0..4a7e7ef 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java @@ -1,13 +1,17 @@ package net.hackyourfuture.tickettrackingsystem.dto.request; + import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record CreateUserRequest( @NotBlank - @Size(min = 3) + @Size(min = 3, max = 255) String name, + @NotBlank @Email - String email) { + @Size(max = 255) + String email +) { } diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java index 0291a7f..bd9dbfc 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java @@ -2,12 +2,21 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; public record UpdateTicketRequest( - @NotBlank String title, + @NotBlank + @Size(max = 255) + String title, + + @Size(max = 5000) String description, - @NotNull Long projectId, - @NotNull TicketStatus status + + @NotNull + Long projectId, + + @NotNull + TicketStatus status ) { -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java index f681080..02eb687 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java @@ -7,11 +7,13 @@ public record UpdateUserRequest( @NotBlank - @Size(min = 3) + @Size(min = 3, max = 255) String name, @NotBlank @Email + @Size(max = 255) String email -) {} \ No newline at end of file +) { +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java index f31acde..d2c9a5b 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java @@ -1,9 +1,7 @@ package net.hackyourfuture.tickettrackingsystem.dto.response; -import java.util.List; - public record TicketUpdateResponse( TicketResponse ticket, - List emailNotifications + boolean emailNotificationsDispatched ) { } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java index 32147c4..b40e7ed 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java @@ -3,6 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import tools.jackson.databind.ObjectMapper; @@ -10,6 +11,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Map; @Service @@ -20,16 +22,25 @@ public class ResendAutomationService { private static final String RESEND_EMAIL_URL = "https://api.resend.com/emails"; - private final ObjectMapper objectMapper = new ObjectMapper(); - private final HttpClient httpClient = HttpClient.newHttpClient(); + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final String apiKey; + private final String fromEmail; - @Value("${resend.api-key}") - private String apiKey; - - @Value("${resend.from-email}") - private String fromEmail; + public ResendAutomationService( + HttpClient resendHttpClient, + ObjectMapper objectMapper, + @Value("${resend.api-key}") String apiKey, + @Value("${resend.from-email}") String fromEmail + ) { + this.httpClient = resendHttpClient; + this.objectMapper = objectMapper; + this.apiKey = apiKey; + this.fromEmail = fromEmail; + } - public EmailSendResult sendTicketUpdatedEmail( + @Async + public void sendTicketUpdatedEmail( String assigneeEmail, Long ticketId, String ticketTitle, @@ -68,6 +79,7 @@ public EmailSendResult sendTicketUpdatedEmail( HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(RESEND_EMAIL_URL)) + .timeout(Duration.ofSeconds(10)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) @@ -79,38 +91,19 @@ public EmailSendResult sendTicketUpdatedEmail( ); if (response.statusCode() < 200 || response.statusCode() >= 300) { - String message = "Resend failed with status " - + response.statusCode() - + ". Body: " - + response.body(); - - logger.warn("Email sending failed for {}. {}", assigneeEmail, message); - - return new EmailSendResult( + logger.warn( + "Email sending failed for {}. Resend returned status {}. Body: {}", assigneeEmail, - false, - message + response.statusCode(), + response.body() ); + return; } logger.info("Email sent successfully to {}", assigneeEmail); - return new EmailSendResult( - assigneeEmail, - true, - "Email sent successfully" - ); - } catch (Exception exception) { - String message = "Could not send email: " + exception.getMessage(); - logger.error("Email sending crashed for {}", assigneeEmail, exception); - - return new EmailSendResult( - assigneeEmail, - false, - message - ); } } @@ -126,4 +119,4 @@ private String escapeHtml(String value) { .replace("\"", """) .replace("'", "'"); } -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java index a74602d..c9e99bc 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java @@ -2,6 +2,8 @@ import jakarta.servlet.http.HttpServletRequest; import net.hackyourfuture.tickettrackingsystem.dto.response.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -10,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import java.time.LocalDateTime; import java.util.LinkedHashMap; @@ -19,6 +22,9 @@ @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger logger = + LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity handleResourceNotFoundException( ResourceNotFoundException exception, @@ -83,6 +89,23 @@ public ResponseEntity handleValidationException( ); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException exception, + HttpServletRequest request + ) { + String message = exception.getCause() instanceof IllegalArgumentException cause + ? cause.getMessage() + : "Invalid value for parameter '" + exception.getName() + "'"; + + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + message, + request.getRequestURI(), + null + ); + } + @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException( HttpServletRequest request @@ -97,8 +120,15 @@ public ResponseEntity handleHttpMessageNotReadableException( @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity handleDataIntegrityViolationException( + DataIntegrityViolationException exception, HttpServletRequest request ) { + logger.warn( + "Database constraint violation at {}: {}", + request.getRequestURI(), + exception.getMostSpecificCause().getMessage() + ); + return buildErrorResponse( HttpStatus.CONFLICT, "Database constraint violation", @@ -109,8 +139,15 @@ public ResponseEntity handleDataIntegrityViolationException( @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException( + Exception exception, HttpServletRequest request ) { + logger.error( + "Unhandled exception at {}", + request.getRequestURI(), + exception + ); + return buildErrorResponse( HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong", diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java index 3f32881..a179134 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java @@ -1,7 +1,43 @@ package net.hackyourfuture.tickettrackingsystem.model; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@RequiredArgsConstructor public enum TicketStatus { - OPEN, - IN_PROGRESS, - CLOSED + OPEN("open"), + IN_PROGRESS("in progress"), + CLOSED("closed"); + + private final String value; + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static TicketStatus fromValue(String input) { + if (input == null) { + return null; + } + + String normalized = input.trim().toLowerCase(); + String enumStyle = normalized + .replace("-", "_") + .replace(" ", "_"); + + return Arrays.stream(values()) + .filter(status -> + status.value.equals(normalized) + || status.name().equalsIgnoreCase(enumStyle) + ) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown status: \"" + input + "\". Allowed: open, in progress, closed" + )); + } } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java index d13fb9d..19b33bc 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java @@ -9,8 +9,12 @@ import org.springframework.stereotype.Repository; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; @Repository @RequiredArgsConstructor @@ -48,7 +52,7 @@ INSERT INTO tickets (title, description, project_id, status) ); } - public List findAll(String text, String status) { + public List findAll(String text, TicketStatus status) { StringBuilder sql = new StringBuilder(""" SELECT id, title, description, project_id, status, creation_date, update_date FROM tickets @@ -58,27 +62,25 @@ public List findAll(String text, String status) { List params = new ArrayList<>(); String cleanedText = text == null ? null : text.trim(); - String cleanedStatus = status == null ? null : status.trim(); if (cleanedText != null && !cleanedText.isBlank()) { sql.append(""" AND ( - LOWER(title) LIKE LOWER(?) - OR LOWER(COALESCE(description, '')) LIKE LOWER(?) + strpos(LOWER(title), LOWER(?)) > 0 + OR strpos(LOWER(COALESCE(description, '')), LOWER(?)) > 0 ) """); - String searchText = "%" + cleanedText + "%"; - params.add(searchText); - params.add(searchText); + params.add(cleanedText); + params.add(cleanedText); } - if (cleanedStatus != null && !cleanedStatus.isBlank()) { + if (status != null) { sql.append(""" - AND status = ?::ticket_status - """); + AND status = ?::ticket_status + """); - params.add(cleanedStatus.toUpperCase()); + params.add(status.name()); } sql.append(" ORDER BY id"); @@ -131,13 +133,16 @@ public Ticket update(Ticket ticket) { - public void assignUser(Long ticketId, Long userId) { + public boolean assignUser(Long ticketId, Long userId) { String sql = """ INSERT INTO user_ticket (ticket_id, user_id) VALUES (?, ?) + ON CONFLICT DO NOTHING """; - jdbcTemplate.update(sql, ticketId, userId); + int rowsAffected = jdbcTemplate.update(sql, ticketId, userId); + + return rowsAffected > 0; } public boolean unassignUser(Long ticketId, Long userId) { @@ -152,22 +157,31 @@ public boolean unassignUser(Long ticketId, Long userId) { return rowsAffected > 0; } - public boolean assignmentExists(Long ticketId, Long userId) { - String sql = """ - SELECT COUNT(*) - FROM user_ticket - WHERE ticket_id = ? - AND user_id = ? - """; + public Map> findAssignedUserIdsByTicketIds(Collection ticketIds) { + if (ticketIds.isEmpty()) { + return Map.of(); + } - Integer count = jdbcTemplate.queryForObject( - sql, - Integer.class, - ticketId, - userId - ); + String placeholders = ticketIds.stream() + .map(id -> "?") + .collect(Collectors.joining(",")); - return count != null && count > 0; + String sql = """ + SELECT ticket_id, user_id + FROM user_ticket + WHERE ticket_id IN (%s) + ORDER BY ticket_id, user_id + """.formatted(placeholders); + + return jdbcTemplate.query(sql, rs -> { + Map> result = new HashMap<>(); + while (rs.next()) { + long ticketId = rs.getLong("ticket_id"); + long userId = rs.getLong("user_id"); + result.computeIfAbsent(ticketId, k -> new ArrayList<>()).add(userId); + } + return result; + }, ticketIds.toArray()); } public List findAssignedUserIds(Long ticketId) { diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java index 40dab49..f0a9f8e 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java @@ -4,6 +4,7 @@ import net.hackyourfuture.tickettrackingsystem.dto.response.ProjectSummaryResponse; import net.hackyourfuture.tickettrackingsystem.repository.ProjectRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -13,7 +14,8 @@ public class ProjectService { private final ProjectRepository projectRepository; + @Transactional(readOnly = true) public List getAllProjects() { return projectRepository.findAllProjectSummaries(); } -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java index 67dc058..d751fed 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java @@ -3,31 +3,35 @@ import lombok.RequiredArgsConstructor; import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; -import net.hackyourfuture.tickettrackingsystem.dto.response.EmailNotificationResponse; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; -import net.hackyourfuture.tickettrackingsystem.email.EmailSendResult; import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; import net.hackyourfuture.tickettrackingsystem.model.Ticket; +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; import net.hackyourfuture.tickettrackingsystem.model.User; import net.hackyourfuture.tickettrackingsystem.repository.ProjectRepository; import net.hackyourfuture.tickettrackingsystem.repository.TicketRepository; import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; import java.util.Objects; @Service @RequiredArgsConstructor public class TicketService { + private static final String SYSTEM_ACTOR = "System"; + private final TicketRepository ticketRepository; private final ProjectRepository projectRepository; private final UserRepository userRepository; private final ResendAutomationService resendAutomationService; + @Transactional public TicketResponse createTicket(CreateTicketRequest request) { if (!projectRepository.existsById(request.projectId())) { throw new ResourceNotFoundException( @@ -50,13 +54,27 @@ public TicketResponse createTicket(CreateTicketRequest request) { return toTicketResponse(createdTicket); } - public List getAllTickets(String text, String status) { - return ticketRepository.findAll(text, status) - .stream() - .map(this::toTicketResponse) + @Transactional(readOnly = true) + public List getAllTickets(String text, TicketStatus status) { + List tickets = ticketRepository.findAll(text, status); + + if (tickets.isEmpty()) { + return List.of(); + } + + List ticketIds = tickets.stream().map(Ticket::id).toList(); + Map> assigneesByTicket = + ticketRepository.findAssignedUserIdsByTicketIds(ticketIds); + + return tickets.stream() + .map(ticket -> toTicketResponse( + ticket, + assigneesByTicket.getOrDefault(ticket.id(), List.of()) + )) .toList(); } + @Transactional(readOnly = true) public TicketResponse getTicketById(Long id) { Ticket ticket = ticketRepository.findById(id) .orElseThrow(() -> @@ -68,6 +86,7 @@ public TicketResponse getTicketById(Long id) { return toTicketResponse(ticket); } + @Transactional public TicketUpdateResponse updateTicket(Long id, UpdateTicketRequest request) { Ticket existingTicket = ticketRepository.findById(id) .orElseThrow(() -> @@ -94,16 +113,18 @@ public TicketUpdateResponse updateTicket(Long id, UpdateTicketRequest request) { Ticket savedTicket = ticketRepository.update(updatedTicket); - List emailNotifications = - sendTicketUpdateEmails(existingTicket, savedTicket); + boolean emailNotificationsDispatched = + dispatchUpdateEmails(existingTicket, savedTicket); return new TicketUpdateResponse( toTicketResponse(savedTicket), - emailNotifications + emailNotificationsDispatched + ); } - public EmailNotificationResponse assignUserToTicket(Long ticketId, Long userId) { + @Transactional + public TicketResponse assignUserToTicket(Long ticketId, Long userId) { Ticket ticket = ticketRepository.findById(ticketId) .orElseThrow(() -> new ResourceNotFoundException( @@ -118,23 +139,28 @@ public EmailNotificationResponse assignUserToTicket(Long ticketId, Long userId) ) ); - if (ticketRepository.assignmentExists(ticketId, userId)) { + boolean inserted = ticketRepository.assignUser(ticketId, userId); + + if (!inserted) { throw new IllegalArgumentException( "User " + userId + " is already assigned to ticket " + ticketId ); } - ticketRepository.assignUser(ticketId, userId); - - return sendSingleTicketEmail( + resendAutomationService.sendTicketUpdatedEmail( user.email(), - ticket, - "System", + ticket.id(), + ticket.title(), + String.valueOf(ticket.status()), + SYSTEM_ACTOR, "You have been assigned to this ticket." ); + + return toTicketResponse(ticket); } - public EmailNotificationResponse unassignUserFromTicket(Long ticketId, Long userId) { + @Transactional + public TicketResponse unassignUserFromTicket(Long ticketId, Long userId) { Ticket ticket = ticketRepository.findById(ticketId) .orElseThrow(() -> new ResourceNotFoundException( @@ -157,60 +183,40 @@ public EmailNotificationResponse unassignUserFromTicket(Long ticketId, Long user ); } - return sendSingleTicketEmail( + resendAutomationService.sendTicketUpdatedEmail( user.email(), - ticket, - "System", + ticket.id(), + ticket.title(), + String.valueOf(ticket.status()), + SYSTEM_ACTOR, "You have been removed from this ticket." ); - } + return toTicketResponse(ticket); + } - private List sendTicketUpdateEmails( - Ticket existingTicket, - Ticket savedTicket - ) { + private boolean dispatchUpdateEmails(Ticket existingTicket, Ticket savedTicket) { List assigneeEmails = ticketRepository.findAssigneeEmailsByTicketId(savedTicket.id()); if (assigneeEmails.isEmpty()) { - return List.of(); + return false; } String changes = buildChanges(existingTicket, savedTicket); - return assigneeEmails.stream() - .map(assigneeEmail -> - sendSingleTicketEmail( - assigneeEmail, - savedTicket, - "System", - changes - ) - ) - .toList(); - } - - private EmailNotificationResponse sendSingleTicketEmail( - String assigneeEmail, - Ticket ticket, - String updatedBy, - String changes - ) { - EmailSendResult result = resendAutomationService.sendTicketUpdatedEmail( - assigneeEmail, - ticket.id(), - ticket.title(), - String.valueOf(ticket.status()), - updatedBy, - changes - ); + for (String email : assigneeEmails) { + resendAutomationService.sendTicketUpdatedEmail( + email, + savedTicket.id(), + savedTicket.title(), + String.valueOf(savedTicket.status()), + SYSTEM_ACTOR, + changes + ); + } - return new EmailNotificationResponse( - result.recipientEmail(), - result.sent(), - result.message() - ); + return true; } private String buildChanges(Ticket oldTicket, Ticket newTicket) { @@ -252,15 +258,22 @@ private String buildChanges(Ticket oldTicket, Ticket newTicket) { } private TicketResponse toTicketResponse(Ticket ticket) { + return toTicketResponse( + ticket, + ticketRepository.findAssignedUserIds(ticket.id()) + ); + } + + private TicketResponse toTicketResponse(Ticket ticket, List assignedUserIds) { return new TicketResponse( ticket.id(), ticket.title(), ticket.description(), ticket.projectId(), ticket.status(), - ticketRepository.findAssignedUserIds(ticket.id()), + assignedUserIds, ticket.creationDate(), ticket.updateDate() ); } -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java index f8a79b7..825895b 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java @@ -9,6 +9,7 @@ import net.hackyourfuture.tickettrackingsystem.model.User; import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -19,6 +20,7 @@ public class UserService { private final UserRepository userRepository; + @Transactional public UserResponse createUser(CreateUserRequest request) { if (userRepository.findByEmail(request.email()).isPresent()) { throw new DuplicateEmailException( @@ -34,6 +36,7 @@ public UserResponse createUser(CreateUserRequest request) { return toUserResponse(createdUser); } + @Transactional(readOnly = true) public List getAllUsers() { return userRepository.findAll() .stream() @@ -41,6 +44,7 @@ public List getAllUsers() { .toList(); } + @Transactional(readOnly = true) public UserResponse getUserById(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> @@ -50,6 +54,7 @@ public UserResponse getUserById(Long id) { return toUserResponse(user); } + @Transactional public UserResponse updateUser(Long id, UpdateUserRequest request) { User existingUser = userRepository.findById(id) .orElseThrow(() -> @@ -76,6 +81,7 @@ public UserResponse updateUser(Long id, UpdateUserRequest request) { return toUserResponse(savedUser); } + @Transactional public void deleteUser(Long id) { boolean deleted = userRepository.deleteById(id); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 000b549..9828684 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,5 +4,6 @@ spring.datasource.url=jdbc:postgresql://localhost:5432/ticket_tracker spring.datasource.username=ticket_user spring.datasource.password=ticket_password spring.datasource.driver-class-name=org.postgresql.Driver -resend.api-key=${RESEND_API_KEY} -resend.from-email=Ticket Tracking \ No newline at end of file + +resend.api-key=${RESEND_API_KEY:} +resend.from-email=${RESEND_FROM_EMAIL:Ticket Tracking } \ No newline at end of file diff --git a/src/main/resources/db/schema.sql b/src/main/resources/schema.sql similarity index 56% rename from src/main/resources/db/schema.sql rename to src/main/resources/schema.sql index b872de0..3d65aa0 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/schema.sql @@ -1,20 +1,31 @@ +DROP TABLE IF EXISTS user_ticket; +DROP TABLE IF EXISTS tickets; +DROP TABLE IF EXISTS projects; +DROP TABLE IF EXISTS users; + +DROP TYPE IF EXISTS ticket_status; + CREATE TYPE ticket_status AS ENUM ( - 'OPEN', 'IN_PROGRESS', 'CLOSED'); + 'OPEN', + 'IN_PROGRESS', + 'CLOSED' + ); -CREATE TABLE users ( - id bigserial primary key , +CREATE TABLE IF NOT EXISTS users ( + id bigserial primary key, name varchar(255) not null CHECK (char_length(name) >= 3), email varchar(255) not null unique - ); -CREATE TABLE projects ( - id bigserial primary key , + +CREATE TABLE IF NOT EXISTS projects ( + id bigserial primary key, name varchar(255) not null CHECK (char_length(name) >= 3) ); -CREATE TABLE tickets ( - id bigserial primary key , + +CREATE TABLE IF NOT EXISTS tickets ( + id bigserial primary key, title varchar(255) not null, - description varchar(255) , + description TEXT, project_id BIGINT NOT NULL, status ticket_status not null, creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -22,14 +33,13 @@ CREATE TABLE tickets ( CONSTRAINT fk_ticket_project FOREIGN KEY (project_id) REFERENCES projects(id) - ); -CREATE TABLE user_ticket ( +CREATE TABLE IF NOT EXISTS user_ticket ( user_id BIGINT NOT NULL, ticket_id BIGINT NOT NULL, - primary key (user_id, ticket_id), + primary key (user_id, ticket_id), CONSTRAINT fk_user_ticket_user FOREIGN KEY (user_id) @@ -39,16 +49,13 @@ CREATE TABLE user_ticket ( FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE - ); +); -CREATE INDEX idx_tickets_status +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); -CREATE INDEX idx_tickets_project_id +CREATE INDEX IF NOT EXISTS idx_tickets_project_id ON tickets(project_id); -CREATE INDEX idx_user_ticket_user_id - ON user_ticket(user_id); - -CREATE INDEX idx_user_ticket_ticket_id - ON user_ticket(ticket_id); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_user_ticket_ticket_id + ON user_ticket(ticket_id); diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java index b021842..cf0ee4b 100644 --- a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java @@ -1,8 +1,6 @@ package net.hackyourfuture.tickettrackingsystem.controller; -import net.hackyourfuture.tickettrackingsystem.email.EmailSendResult; import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,8 +12,6 @@ import org.springframework.test.web.servlet.MockMvc; import static org.hamcrest.Matchers.hasSize; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -41,27 +37,9 @@ class TicketControllerIntegrationTest { @MockitoBean private ResendAutomationService resendAutomationService; - @BeforeEach - void setUp() { - when(resendAutomationService.sendTicketUpdatedEmail( - anyString(), - anyLong(), - anyString(), - anyString(), - anyString(), - anyString() - )).thenAnswer(invocation -> - new EmailSendResult( - invocation.getArgument(0), - true, - "Email sent successfully" - ) - ); - } - @Test void getAllTickets_withoutFilters_returnsAllTickets() throws Exception { - mockMvc.perform(get("/tickets")) + mockMvc.perform(get("/api/v1/tickets")) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(2))) .andExpect(jsonPath("$[0].title").value("Bug login")) @@ -70,36 +48,49 @@ void getAllTickets_withoutFilters_returnsAllTickets() throws Exception { @Test void getAllTickets_withTextAndStatus_usesAndLogic() throws Exception { - mockMvc.perform(get("/tickets") + mockMvc.perform(get("/api/v1/tickets") .param("text", "bug") - .param("status", "OPEN")) + .param("status", "open")) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].title").value("Bug login")) - .andExpect(jsonPath("$[0].status").value("OPEN")); + .andExpect(jsonPath("$[0].status").value("open")); + } + + @Test + void getAllTickets_withInvalidStatus_returnsBadRequest() throws Exception { + mockMvc.perform(get("/api/v1/tickets") + .param("status", "not-a-real-status")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void getAllTickets_withWildcardInText_treatsItAsLiteral() throws Exception { + mockMvc.perform(get("/api/v1/tickets") + .param("text", "%")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); } @Test - void updateTicket_returnsTicketAndEmailNotificationResult() throws Exception { + void updateTicket_returnsTicketAndEmailDispatchedFlag() throws Exception { String requestBody = """ { "title": "Bug login updated", "description": "Login button is still broken", "projectId": 1, - "status": "IN_PROGRESS" + "status": "in progress" } """; - mockMvc.perform(put("/tickets/1") + mockMvc.perform(put("/api/v1/tickets/1") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()) .andExpect(jsonPath("$.ticket.id").value(1)) .andExpect(jsonPath("$.ticket.title").value("Bug login updated")) - .andExpect(jsonPath("$.ticket.status").value("IN_PROGRESS")) - .andExpect(jsonPath("$.emailNotifications", hasSize(1))) - .andExpect(jsonPath("$.emailNotifications[0].recipientEmail").value("alice@example.com")) - .andExpect(jsonPath("$.emailNotifications[0].sent").value(true)) - .andExpect(jsonPath("$.emailNotifications[0].message").value("Email sent successfully")); + .andExpect(jsonPath("$.ticket.status").value("in progress")) + .andExpect(jsonPath("$.emailNotificationsDispatched").value(true)); } -} \ No newline at end of file +} diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java index afeed25..12d5175 100644 --- a/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java @@ -2,9 +2,8 @@ import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; -import net.hackyourfuture.tickettrackingsystem.dto.response.EmailNotificationResponse; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; -import net.hackyourfuture.tickettrackingsystem.email.EmailSendResult; import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; import net.hackyourfuture.tickettrackingsystem.model.Ticket; @@ -65,7 +64,7 @@ void createTicket_whenProjectDoesNotExist_throwsResourceNotFoundException() { } @Test - void updateTicket_sendsEmailsToAllAssigneesAndReturnsEmailResults() { + void updateTicket_dispatchesEmailToEveryAssigneeAndFlagsResponse() { Ticket existingTicket = new Ticket( 1L, "Bug login", @@ -101,28 +100,11 @@ void updateTicket_sendsEmailsToAllAssigneesAndReturnsEmailResults() { when(ticketRepository.findAssignedUserIds(1L)) .thenReturn(List.of(2L, 3L)); - when(resendAutomationService.sendTicketUpdatedEmail( - anyString(), - eq(1L), - anyString(), - anyString(), - anyString(), - anyString() - )).thenAnswer(invocation -> - new EmailSendResult( - invocation.getArgument(0), - true, - "Email sent successfully" - ) - ); - TicketUpdateResponse response = ticketService.updateTicket(1L, request); assertEquals("Bug login fixed", response.ticket().title()); assertEquals(TicketStatus.IN_PROGRESS, response.ticket().status()); - assertEquals(2, response.emailNotifications().size()); - assertTrue(response.emailNotifications().get(0).sent()); - assertTrue(response.emailNotifications().get(1).sent()); + assertTrue(response.emailNotificationsDispatched()); verify(resendAutomationService, times(2)).sendTicketUpdatedEmail( anyString(), @@ -135,7 +117,50 @@ void updateTicket_sendsEmailsToAllAssigneesAndReturnsEmailResults() { } @Test - void assignUserToTicket_whenEmailFails_assignmentStillSucceedsAndReturnsFailedEmailResult() { + void updateTicket_withNoAssignees_doesNotDispatchAnyEmail() { + Ticket existingTicket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + Ticket savedTicket = new Ticket( + 1L, + "Bug login fixed", + "Login button is broken", + 1L, + TicketStatus.IN_PROGRESS, + existingTicket.creationDate(), + LocalDateTime.now() + ); + + UpdateTicketRequest request = new UpdateTicketRequest( + "Bug login fixed", + "Login button is broken", + 1L, + TicketStatus.IN_PROGRESS + ); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(existingTicket)); + when(projectRepository.existsById(1L)).thenReturn(true); + when(ticketRepository.update(any(Ticket.class))).thenReturn(savedTicket); + when(ticketRepository.findAssigneeEmailsByTicketId(1L)).thenReturn(List.of()); + when(ticketRepository.findAssignedUserIds(1L)).thenReturn(List.of()); + + TicketUpdateResponse response = ticketService.updateTicket(1L, request); + + assertFalse(response.emailNotificationsDispatched()); + verify(resendAutomationService, never()).sendTicketUpdatedEmail( + anyString(), anyLong(), anyString(), anyString(), anyString(), anyString() + ); + } + + @Test + void assignUserToTicket_whenAssignmentAlreadyExists_throwsAndDoesNotEmail() { Ticket ticket = new Ticket( 1L, "Bug login", @@ -146,38 +171,53 @@ void assignUserToTicket_whenEmailFails_assignmentStillSucceedsAndReturnsFailedEm null ); - User user = new User( - 2L, - "Alice Doe", - "alice@example.com" + User user = new User(2L, "Alice Doe", "alice@example.com"); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(ticketRepository.assignUser(1L, 2L)).thenReturn(false); + + assertThrows( + IllegalArgumentException.class, + () -> ticketService.assignUserToTicket(1L, 2L) + ); + + verify(resendAutomationService, never()).sendTicketUpdatedEmail( + anyString(), anyLong(), anyString(), anyString(), anyString(), anyString() ); + } + + @Test + void assignUserToTicket_whenInsertSucceeds_dispatchesEmailAndReturnsTicket() { + Ticket ticket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + User user = new User(2L, "Alice Doe", "alice@example.com"); when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); when(userRepository.findById(2L)).thenReturn(Optional.of(user)); - when(ticketRepository.assignmentExists(1L, 2L)).thenReturn(false); + when(ticketRepository.assignUser(1L, 2L)).thenReturn(true); + when(ticketRepository.findAssignedUserIds(1L)).thenReturn(List.of(2L)); + + TicketResponse response = ticketService.assignUserToTicket(1L, 2L); - when(resendAutomationService.sendTicketUpdatedEmail( + assertEquals(1L, response.id()); + assertTrue(response.assignedUserIds().contains(2L)); + + verify(resendAutomationService).sendTicketUpdatedEmail( eq("alice@example.com"), eq(1L), anyString(), anyString(), eq("System"), anyString() - )).thenReturn( - new EmailSendResult( - "alice@example.com", - false, - "Resend failed with status 401" - ) ); - - EmailNotificationResponse response = - ticketService.assignUserToTicket(1L, 2L); - - assertEquals("alice@example.com", response.recipientEmail()); - assertFalse(response.sent()); - assertEquals("Resend failed with status 401", response.message()); - - verify(ticketRepository).assignUser(1L, 2L); } -} \ No newline at end of file +}