From c69d9bafeeb7092262acd3f8754f3d69480be087 Mon Sep 17 00:00:00 2001 From: Adam Majava Date: Fri, 24 Apr 2026 12:36:06 +0200 Subject: [PATCH 1/2] feat: enhance activity log functionality with sorting and additional user details --- .../controller/ActivityLogController.java | 4 +- .../controller/DocumentController.java | 8 ++- .../controller/SupportTicketController.java | 3 +- .../web/DocumentViewController.java | 4 +- .../controller/web/TicketViewController.java | 13 +++-- .../dto/activitylog/ActivityLogDto.java | 8 +-- .../mapper/ActivityLogMapper.java | 8 +-- .../service/ActivityLogService.java | 13 +++-- .../service/CommentService.java | 40 ++++++++------- .../service/DocumentService.java | 2 +- .../service/SupportTicketService.java | 2 +- .../service/impl/DocumentServiceImpl.java | 40 +++++++++++++-- .../impl/SupportTicketServiceImpl.java | 49 +++++++++++++++++-- src/main/resources/static/css/app.css | 17 +++++++ .../resources/templates/tickets/detail.html | 20 ++++++++ .../controller/ActivityLogControllerTest.java | 45 +++++++++++++---- .../controller/CommentControllerTest.java | 5 +- .../controller/DocumentControllerTest.java | 2 +- .../SupportTicketControllerTest.java | 2 +- .../mapper/ActivityLogMapperTest.java | 8 ++- .../service/ActivityLogServiceTest.java | 43 +++++++++++++--- .../service/CommentServiceTest.java | 39 ++++++++++++++- .../service/DocumentServiceTest.java | 21 +++++++- .../service/SupportTicketServiceTest.java | 24 ++++++++- 24 files changed, 346 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/group1/projectbackend/controller/ActivityLogController.java b/src/main/java/org/group1/projectbackend/controller/ActivityLogController.java index 35bd249..797fb70 100644 --- a/src/main/java/org/group1/projectbackend/controller/ActivityLogController.java +++ b/src/main/java/org/group1/projectbackend/controller/ActivityLogController.java @@ -44,7 +44,7 @@ public ResponseEntity getActivityLogById(@PathVariable Long acti @GetMapping("/ticket/{supportTicketId}") public ResponseEntity> getActivityLogsBySupportTicketId( @PathVariable Long supportTicketId, - @RequestParam(defaultValue = "asc") String sortDirection + @RequestParam(defaultValue = "desc") String sortDirection ) { return ResponseEntity.ok( activityLogService.getActivityLogsBySupportTicketId(supportTicketId, sortDirection) @@ -61,4 +61,4 @@ public ResponseEntity> getActivityLogsByUserId( activityLogService.getActivityLogsByUserId(userId, sortDirection) ); } -} \ No newline at end of file +} diff --git a/src/main/java/org/group1/projectbackend/controller/DocumentController.java b/src/main/java/org/group1/projectbackend/controller/DocumentController.java index b6c5bf3..13956c6 100644 --- a/src/main/java/org/group1/projectbackend/controller/DocumentController.java +++ b/src/main/java/org/group1/projectbackend/controller/DocumentController.java @@ -1,6 +1,7 @@ package org.group1.projectbackend.controller; import java.nio.charset.StandardCharsets; +import java.security.Principal; import java.util.List; import org.group1.projectbackend.dto.document.DocumentDownloadResponse; import org.group1.projectbackend.dto.document.DocumentResponse; @@ -57,8 +58,11 @@ public ResponseEntity downloadDocument(@PathVariable Long documentId) } @DeleteMapping("/documents/{documentId}") - public ResponseEntity deleteDocument(@PathVariable Long documentId) { - documentService.deleteDocument(documentId); + public ResponseEntity deleteDocument( + Principal principal, + @PathVariable Long documentId + ) { + documentService.deleteDocument(principal.getName(), documentId); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/org/group1/projectbackend/controller/SupportTicketController.java b/src/main/java/org/group1/projectbackend/controller/SupportTicketController.java index 7d017f6..d951ca3 100644 --- a/src/main/java/org/group1/projectbackend/controller/SupportTicketController.java +++ b/src/main/java/org/group1/projectbackend/controller/SupportTicketController.java @@ -43,10 +43,11 @@ public ResponseEntity getTicketById(@PathVariable Long id) { @PutMapping("/{id}/status") public ResponseEntity updateTicketStatus( + Principal principal, @PathVariable Long id, @Valid @RequestBody UpdateTicketStatusRequest request ) { - TicketResponse updatedTicket = supportTicketService.updateStatus(id, request); + TicketResponse updatedTicket = supportTicketService.updateStatus(principal.getName(), id, request); return ResponseEntity.ok(updatedTicket); } diff --git a/src/main/java/org/group1/projectbackend/controller/web/DocumentViewController.java b/src/main/java/org/group1/projectbackend/controller/web/DocumentViewController.java index 8699199..30fe73b 100644 --- a/src/main/java/org/group1/projectbackend/controller/web/DocumentViewController.java +++ b/src/main/java/org/group1/projectbackend/controller/web/DocumentViewController.java @@ -1,5 +1,6 @@ package org.group1.projectbackend.controller.web; +import java.security.Principal; import org.group1.projectbackend.service.DocumentService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -26,10 +27,11 @@ public String listDocuments(Model model) { @PostMapping("/documents/{documentId}/delete") public String deleteDocument( @PathVariable Long documentId, + Principal principal, RedirectAttributes redirectAttributes ) { try { - documentService.deleteDocument(documentId); + documentService.deleteDocument(principal.getName(), documentId); redirectAttributes.addFlashAttribute("documentSuccess", "Dokumentet togs bort."); } catch (RuntimeException ex) { redirectAttributes.addFlashAttribute("documentError", "Dokumentet kunde inte tas bort."); diff --git a/src/main/java/org/group1/projectbackend/controller/web/TicketViewController.java b/src/main/java/org/group1/projectbackend/controller/web/TicketViewController.java index c4b8ccc..7857aa3 100644 --- a/src/main/java/org/group1/projectbackend/controller/web/TicketViewController.java +++ b/src/main/java/org/group1/projectbackend/controller/web/TicketViewController.java @@ -11,6 +11,7 @@ import org.group1.projectbackend.entity.enums.TicketStatus; import org.group1.projectbackend.exception.ResourceNotFoundException; import org.group1.projectbackend.repository.UserRepository; +import org.group1.projectbackend.service.ActivityLogService; import org.group1.projectbackend.service.CommentService; import org.group1.projectbackend.service.DocumentService; import org.group1.projectbackend.service.SupportTicketService; @@ -29,17 +30,20 @@ public class TicketViewController { private final SupportTicketService supportTicketService; + private final ActivityLogService activityLogService; private final CommentService commentService; private final DocumentService documentService; private final UserRepository userRepository; public TicketViewController( SupportTicketService supportTicketService, + ActivityLogService activityLogService, CommentService commentService, DocumentService documentService, UserRepository userRepository ) { this.supportTicketService = supportTicketService; + this.activityLogService = activityLogService; this.commentService = commentService; this.documentService = documentService; this.userRepository = userRepository; @@ -54,6 +58,7 @@ public String listTickets(Model model) { @GetMapping("/tickets/{id}") public String showTicket(@PathVariable Long id, Model model) { model.addAttribute("ticket", supportTicketService.getTicketById(id)); + model.addAttribute("activityLogs", activityLogService.getActivityLogsBySupportTicketId(id, "desc")); model.addAttribute("comments", commentService.getCommentsBySupportTicketId(id, "asc")); model.addAttribute("documents", documentService.listDocumentsForTicket(id)); model.addAttribute("statuses", TicketStatus.values()); @@ -135,10 +140,11 @@ public String uploadDocument( public String deleteDocument( @PathVariable Long ticketId, @PathVariable Long documentId, + Principal principal, RedirectAttributes redirectAttributes ) { try { - documentService.deleteDocument(documentId); + documentService.deleteDocument(principal.getName(), documentId); redirectAttributes.addFlashAttribute("documentSuccess", "Dokumentet togs bort."); } catch (RuntimeException ex) { redirectAttributes.addFlashAttribute("documentError", "Dokumentet kunde inte tas bort."); @@ -150,9 +156,10 @@ public String deleteDocument( @PostMapping("/tickets/{id}/status") public String updateTicketStatus( @PathVariable Long id, - @RequestParam TicketStatus status + @RequestParam TicketStatus status, + Principal principal ) { - supportTicketService.updateStatus(id, new UpdateTicketStatusRequest(status)); + supportTicketService.updateStatus(principal.getName(), id, new UpdateTicketStatusRequest(status)); return "redirect:/tickets/" + id; } diff --git a/src/main/java/org/group1/projectbackend/dto/activitylog/ActivityLogDto.java b/src/main/java/org/group1/projectbackend/dto/activitylog/ActivityLogDto.java index 3fb17e7..e003ec4 100644 --- a/src/main/java/org/group1/projectbackend/dto/activitylog/ActivityLogDto.java +++ b/src/main/java/org/group1/projectbackend/dto/activitylog/ActivityLogDto.java @@ -13,10 +13,12 @@ public class ActivityLogDto { private Long id; - private ActivityType activityType; - private String description; private Long userId; + private String username; private Long supportTicketId; + private String ticketTitle; + private ActivityType activityType; + private String description; private LocalDateTime createdAt; -} \ No newline at end of file +} diff --git a/src/main/java/org/group1/projectbackend/mapper/ActivityLogMapper.java b/src/main/java/org/group1/projectbackend/mapper/ActivityLogMapper.java index ebd88a3..8577ecc 100644 --- a/src/main/java/org/group1/projectbackend/mapper/ActivityLogMapper.java +++ b/src/main/java/org/group1/projectbackend/mapper/ActivityLogMapper.java @@ -17,10 +17,12 @@ public ActivityLogDto toDto(ActivityLog activityLog) { return new ActivityLogDto( activityLog.getId(), - activityLog.getActivityType(), - activityLog.getDescription(), activityLog.getUser() != null ? activityLog.getUser().getId() : null, + activityLog.getUser() != null ? activityLog.getUser().getUsername() : null, activityLog.getSupportTicket() != null ? activityLog.getSupportTicket().getId() : null, + activityLog.getSupportTicket() != null ? activityLog.getSupportTicket().getTitle() : null, + activityLog.getActivityType(), + activityLog.getDescription(), activityLog.getCreatedAt() ); } @@ -39,4 +41,4 @@ public ActivityLog toEntity(CreateActivityLogDto dto, User user, SupportTicket s null ); } -} \ No newline at end of file +} diff --git a/src/main/java/org/group1/projectbackend/service/ActivityLogService.java b/src/main/java/org/group1/projectbackend/service/ActivityLogService.java index b117760..b80e3d9 100644 --- a/src/main/java/org/group1/projectbackend/service/ActivityLogService.java +++ b/src/main/java/org/group1/projectbackend/service/ActivityLogService.java @@ -5,6 +5,7 @@ import org.group1.projectbackend.entity.ActivityLog; import org.group1.projectbackend.entity.SupportTicket; import org.group1.projectbackend.entity.User; +import org.group1.projectbackend.exception.ResourceNotFoundException; import org.group1.projectbackend.mapper.ActivityLogMapper; import org.group1.projectbackend.repository.ActivityLogRepository; import org.group1.projectbackend.repository.SupportTicketRepository; @@ -35,12 +36,12 @@ public ActivityLogService(ActivityLogRepository activityLogRepository, public ActivityLogDto createActivityLog(CreateActivityLogDto dto) { User user = userRepository.findById(dto.getUserId()) - .orElseThrow(() -> new RuntimeException("User not found with id: " + dto.getUserId())); + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + dto.getUserId())); SupportTicket supportTicket = null; if (dto.getSupportTicketId() != null) { supportTicket = supportTicketRepository.findById(dto.getSupportTicketId()) - .orElseThrow(() -> new RuntimeException("Support ticket not found with id: " + dto.getSupportTicketId())); + .orElseThrow(() -> new ResourceNotFoundException("Support ticket not found with id: " + dto.getSupportTicketId())); } ActivityLog activityLog = activityLogMapper.toEntity(dto, user, supportTicket); @@ -58,12 +59,16 @@ public List getAllActivityLogs() { public ActivityLogDto getActivityLogById(Long id) { ActivityLog activityLog = activityLogRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Activity log not found with id: " + id)); + .orElseThrow(() -> new ResourceNotFoundException("Activity log not found with id: " + id)); return activityLogMapper.toDto(activityLog); } public List getActivityLogsBySupportTicketId(Long supportTicketId, String sortDirection) { + if (!supportTicketRepository.existsById(supportTicketId)) { + throw new ResourceNotFoundException("Support ticket not found with id: " + supportTicketId); + } + Sort sort = "desc".equalsIgnoreCase(sortDirection) ? Sort.by("createdAt").descending() : Sort.by("createdAt").ascending(); @@ -84,4 +89,4 @@ public List getActivityLogsByUserId(Long userId, String sortDire .map(activityLogMapper::toDto) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/group1/projectbackend/service/CommentService.java b/src/main/java/org/group1/projectbackend/service/CommentService.java index 6d803df..2358f51 100644 --- a/src/main/java/org/group1/projectbackend/service/CommentService.java +++ b/src/main/java/org/group1/projectbackend/service/CommentService.java @@ -45,17 +45,12 @@ public CommentDto createComment(CreateCommentDto dto) { Comment comment = commentMapper.toEntity(dto, user, ticket); Comment savedComment = commentRepository.save(comment); - try { - CreateActivityLogDto logDto = new CreateActivityLogDto( - ActivityType.COMMENT_CREATED, - "Comment created for ticket id: " + ticket.getId(), - user.getId(), - ticket.getId() - ); - activityLogService.createActivityLog(logDto); - } catch (Exception e) { - System.err.println("Failed to create activity log: " + e.getMessage()); - } + logActivitySafely( + ActivityType.COMMENT_CREATED, + "Comment created for ticket id: " + ticket.getId(), + user.getId(), + ticket.getId() + ); return commentMapper.toDto(savedComment); } @@ -87,14 +82,12 @@ public void deleteComment(Long id) { .orElseThrow(() -> new RuntimeException("Comment not found with id: " + id)); commentRepository.delete(comment); - CreateActivityLogDto logDto = new CreateActivityLogDto( + logActivitySafely( ActivityType.COMMENT_DELETED, "Comment deleted with id: " + comment.getId(), comment.getUser().getId(), comment.getTicket().getId() ); - - activityLogService.createActivityLog(logDto); } // Update comment @@ -105,16 +98,27 @@ public CommentDto updateComment(Long id, UpdateCommentDto dto) { commentMapper.updateEntity(dto, existingComment); Comment updatedComment = commentRepository.save(existingComment); - CreateActivityLogDto logDto = new CreateActivityLogDto( + logActivitySafely( ActivityType.COMMENT_UPDATED, "Comment updated with id: " + existingComment.getId(), existingComment.getUser().getId(), existingComment.getTicket().getId() ); - activityLogService.createActivityLog(logDto); - return commentMapper.toDto(updatedComment); } -} \ No newline at end of file + + private void logActivitySafely(ActivityType activityType, String description, Long userId, Long ticketId) { + try { + activityLogService.createActivityLog(new CreateActivityLogDto( + activityType, + description, + userId, + ticketId + )); + } catch (RuntimeException ex) { + System.err.println("Failed to create activity log: " + ex.getMessage()); + } + } +} diff --git a/src/main/java/org/group1/projectbackend/service/DocumentService.java b/src/main/java/org/group1/projectbackend/service/DocumentService.java index 6870b52..f164e9f 100644 --- a/src/main/java/org/group1/projectbackend/service/DocumentService.java +++ b/src/main/java/org/group1/projectbackend/service/DocumentService.java @@ -15,5 +15,5 @@ public interface DocumentService { DocumentDownloadResponse downloadDocument(Long documentId); - void deleteDocument(Long documentId); + void deleteDocument(String username, Long documentId); } diff --git a/src/main/java/org/group1/projectbackend/service/SupportTicketService.java b/src/main/java/org/group1/projectbackend/service/SupportTicketService.java index 289c0e6..68f4611 100644 --- a/src/main/java/org/group1/projectbackend/service/SupportTicketService.java +++ b/src/main/java/org/group1/projectbackend/service/SupportTicketService.java @@ -11,7 +11,7 @@ public interface SupportTicketService { TicketResponse getTicketById(Long ticketId); - TicketResponse updateStatus(Long ticketId, UpdateTicketStatusRequest request); + TicketResponse updateStatus(String username, Long ticketId, UpdateTicketStatusRequest request); List getAllTickets(); diff --git a/src/main/java/org/group1/projectbackend/service/impl/DocumentServiceImpl.java b/src/main/java/org/group1/projectbackend/service/impl/DocumentServiceImpl.java index 03d23fd..32886a7 100644 --- a/src/main/java/org/group1/projectbackend/service/impl/DocumentServiceImpl.java +++ b/src/main/java/org/group1/projectbackend/service/impl/DocumentServiceImpl.java @@ -4,16 +4,19 @@ import java.util.List; import java.util.UUID; import org.group1.projectbackend.config.S3StorageProperties; +import org.group1.projectbackend.dto.activitylog.CreateActivityLogDto; import org.group1.projectbackend.dto.document.DocumentDownloadResponse; import org.group1.projectbackend.dto.document.DocumentResponse; import org.group1.projectbackend.entity.Document; import org.group1.projectbackend.entity.SupportTicket; import org.group1.projectbackend.entity.User; +import org.group1.projectbackend.entity.enums.ActivityType; import org.group1.projectbackend.exception.ResourceNotFoundException; import org.group1.projectbackend.mapper.DocumentMapper; import org.group1.projectbackend.repository.DocumentRepository; import org.group1.projectbackend.repository.SupportTicketRepository; import org.group1.projectbackend.repository.UserRepository; +import org.group1.projectbackend.service.ActivityLogService; import org.group1.projectbackend.service.DocumentService; import org.group1.projectbackend.service.ObjectStorageService; import org.springframework.core.io.Resource; @@ -32,19 +35,22 @@ public class DocumentServiceImpl implements DocumentService { private final UserRepository userRepository; private final ObjectStorageService objectStorageService; private final S3StorageProperties s3StorageProperties; + private final ActivityLogService activityLogService; public DocumentServiceImpl( DocumentRepository documentRepository, SupportTicketRepository supportTicketRepository, UserRepository userRepository, ObjectStorageService objectStorageService, - S3StorageProperties s3StorageProperties + S3StorageProperties s3StorageProperties, + ActivityLogService activityLogService ) { this.documentRepository = documentRepository; this.supportTicketRepository = supportTicketRepository; this.userRepository = userRepository; this.objectStorageService = objectStorageService; this.s3StorageProperties = s3StorageProperties; + this.activityLogService = activityLogService; } @Override @@ -78,7 +84,14 @@ public DocumentResponse uploadDocument(Long ticketId, Long uploadedByUserId, Mul .build(); try { - return DocumentMapper.toResponse(documentRepository.save(document)); + Document savedDocument = documentRepository.save(document); + logActivitySafely( + ActivityType.FILE_UPLOADED, + "File uploaded: " + savedDocument.getFileName(), + uploadedBy.getId(), + ticket.getId() + ); + return DocumentMapper.toResponse(savedDocument); } catch (RuntimeException ex) { deleteUploadedObjectAfterFailedSave(storageKey, ex); throw ex; @@ -113,12 +126,20 @@ public DocumentDownloadResponse downloadDocument(Long documentId) { @Override @Transactional - public void deleteDocument(Long documentId) { + public void deleteDocument(String username, Long documentId) { + User deletedBy = userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found with username: " + username)); Document document = findDocumentById(documentId); String storageKey = document.getStorageKey(); documentRepository.delete(document); deleteObjectAfterCommit(storageKey); + logActivitySafely( + ActivityType.FILE_DELETED, + "File deleted: " + document.getFileName(), + deletedBy.getId(), + document.getTicket().getId() + ); } private Document findDocumentById(Long documentId) { @@ -171,4 +192,17 @@ public void afterCommit() { } }); } + + private void logActivitySafely(ActivityType activityType, String description, Long userId, Long ticketId) { + try { + activityLogService.createActivityLog(new CreateActivityLogDto( + activityType, + description, + userId, + ticketId + )); + } catch (RuntimeException ex) { + System.err.println("Failed to create activity log: " + ex.getMessage()); + } + } } diff --git a/src/main/java/org/group1/projectbackend/service/impl/SupportTicketServiceImpl.java b/src/main/java/org/group1/projectbackend/service/impl/SupportTicketServiceImpl.java index 1248da3..32cf3d1 100644 --- a/src/main/java/org/group1/projectbackend/service/impl/SupportTicketServiceImpl.java +++ b/src/main/java/org/group1/projectbackend/service/impl/SupportTicketServiceImpl.java @@ -1,15 +1,18 @@ package org.group1.projectbackend.service.impl; import java.util.List; +import org.group1.projectbackend.dto.activitylog.CreateActivityLogDto; import org.group1.projectbackend.dto.ticket.CreateTicketRequest; import org.group1.projectbackend.dto.ticket.TicketResponse; import org.group1.projectbackend.dto.ticket.UpdateTicketStatusRequest; import org.group1.projectbackend.entity.SupportTicket; import org.group1.projectbackend.entity.User; +import org.group1.projectbackend.entity.enums.ActivityType; import org.group1.projectbackend.exception.ResourceNotFoundException; import org.group1.projectbackend.mapper.SupportTicketMapper; import org.group1.projectbackend.repository.SupportTicketRepository; import org.group1.projectbackend.repository.UserRepository; +import org.group1.projectbackend.service.ActivityLogService; import org.group1.projectbackend.service.SupportTicketService; import org.springframework.stereotype.Service; @@ -18,10 +21,16 @@ public class SupportTicketServiceImpl implements SupportTicketService { private final SupportTicketRepository supportTicketRepository; private final UserRepository userRepository; + private final ActivityLogService activityLogService; - public SupportTicketServiceImpl(SupportTicketRepository supportTicketRepository, UserRepository userRepository) { + public SupportTicketServiceImpl( + SupportTicketRepository supportTicketRepository, + UserRepository userRepository, + ActivityLogService activityLogService + ) { this.supportTicketRepository = supportTicketRepository; this.userRepository = userRepository; + this.activityLogService = activityLogService; } @Override @@ -41,13 +50,24 @@ public TicketResponse getTicketById(Long ticketId) { } @Override - public TicketResponse updateStatus(Long ticketId, UpdateTicketStatusRequest request) { + public TicketResponse updateStatus(String username, Long ticketId, UpdateTicketStatusRequest request) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found with username: " + username)); SupportTicket ticket = supportTicketRepository.findById(ticketId) .orElseThrow(() -> new ResourceNotFoundException("Ticket not found with id: " + ticketId)); + var previousStatus = ticket.getStatus(); ticket.setStatus(request.status()); - return SupportTicketMapper.toResponse(supportTicketRepository.save(ticket)); + SupportTicket savedTicket = supportTicketRepository.save(ticket); + logActivitySafely( + ActivityType.TICKET_STATUS_CHANGED, + "Ticket status changed from " + previousStatus + " to " + savedTicket.getStatus(), + user.getId(), + savedTicket.getId() + ); + + return SupportTicketMapper.toResponse(savedTicket); } @Override @@ -68,6 +88,27 @@ private TicketResponse saveTicket(CreateTicketRequest request, User user) { .createdBy(user) .build(); - return SupportTicketMapper.toResponse(supportTicketRepository.save(ticket)); + SupportTicket savedTicket = supportTicketRepository.save(ticket); + logActivitySafely( + ActivityType.TICKET_CREATED, + "Ticket created", + user.getId(), + savedTicket.getId() + ); + + return SupportTicketMapper.toResponse(savedTicket); + } + + private void logActivitySafely(ActivityType activityType, String description, Long userId, Long ticketId) { + try { + activityLogService.createActivityLog(new CreateActivityLogDto( + activityType, + description, + userId, + ticketId + )); + } catch (RuntimeException ex) { + System.err.println("Failed to create activity log: " + ex.getMessage()); + } } } diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 6901e9a..e52af6c 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -495,6 +495,23 @@ textarea { color: var(--foreground); } +.activity-log-list .badge { + margin-bottom: 0.75rem; +} + +.activity-log-header { + display: flex; + align-items: center; + justify-content: flex-start; + margin-bottom: 0.2rem; +} + +.activity-log-meta { + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem; +} + small, .muted { color: var(--foreground-muted); diff --git a/src/main/resources/templates/tickets/detail.html b/src/main/resources/templates/tickets/detail.html index 1b010c1..2000d46 100644 --- a/src/main/resources/templates/tickets/detail.html +++ b/src/main/resources/templates/tickets/detail.html @@ -90,6 +90,26 @@

Kommentarer

+
+

Historik

+ +

Det finns ingen aktivitetshistorik för denna ticket ännu.

+ +
    +
  • +
    + TICKET_CREATED +
    +

    Ticket created

    + + alice + - + 2026-04-24 12:20 + +
  • +
+
+

Dokument

diff --git a/src/test/java/org/group1/projectbackend/controller/ActivityLogControllerTest.java b/src/test/java/org/group1/projectbackend/controller/ActivityLogControllerTest.java index 99907f9..a2afbd4 100644 --- a/src/test/java/org/group1/projectbackend/controller/ActivityLogControllerTest.java +++ b/src/test/java/org/group1/projectbackend/controller/ActivityLogControllerTest.java @@ -3,11 +3,14 @@ import org.group1.projectbackend.dto.activitylog.ActivityLogDto; import org.group1.projectbackend.dto.activitylog.CreateActivityLogDto; import org.group1.projectbackend.entity.enums.ActivityType; +import org.group1.projectbackend.exception.GlobalExceptionHandler; +import org.group1.projectbackend.exception.ResourceNotFoundException; import org.group1.projectbackend.service.ActivityLogService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -24,6 +27,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(ActivityLogController.class) +@Import(GlobalExceptionHandler.class) public class ActivityLogControllerTest { @Autowired @@ -38,10 +42,12 @@ public class ActivityLogControllerTest { void setUp() { activityLogDto = new ActivityLogDto( 1L, - ActivityType.TICKET_CREATED, - "Test description", 1L, + "alice", 1L, + "VPN access issue", + ActivityType.TICKET_CREATED, + "Test description", null ); } @@ -68,7 +74,9 @@ void testCreateActivityLog() throws Exception { .andExpect(jsonPath("$.activityType").value("TICKET_CREATED")) .andExpect(jsonPath("$.description").value("Test description")) .andExpect(jsonPath("$.userId").value(1)) - .andExpect(jsonPath("$.supportTicketId").value(1)); + .andExpect(jsonPath("$.username").value("alice")) + .andExpect(jsonPath("$.supportTicketId").value(1)) + .andExpect(jsonPath("$.ticketTitle").value("VPN access issue")); } @Test @@ -83,7 +91,9 @@ void testGetAllActivityLogs() throws Exception { .andExpect(jsonPath("$[0].activityType").value("TICKET_CREATED")) .andExpect(jsonPath("$[0].description").value("Test description")) .andExpect(jsonPath("$[0].userId").value(1)) - .andExpect(jsonPath("$[0].supportTicketId").value(1)); + .andExpect(jsonPath("$[0].username").value("alice")) + .andExpect(jsonPath("$[0].supportTicketId").value(1)) + .andExpect(jsonPath("$[0].ticketTitle").value("VPN access issue")); } @Test @@ -98,13 +108,15 @@ void testGetActivityLogById() throws Exception { .andExpect(jsonPath("$.activityType").value("TICKET_CREATED")) .andExpect(jsonPath("$.description").value("Test description")) .andExpect(jsonPath("$.userId").value(1)) - .andExpect(jsonPath("$.supportTicketId").value(1)); + .andExpect(jsonPath("$.username").value("alice")) + .andExpect(jsonPath("$.supportTicketId").value(1)) + .andExpect(jsonPath("$.ticketTitle").value("VPN access issue")); } @Test @WithMockUser void testGetActivityLogsBySupportTicketId() throws Exception { - when(activityLogService.getActivityLogsBySupportTicketId(1L, "asc")) + when(activityLogService.getActivityLogsBySupportTicketId(1L, "desc")) .thenReturn(List.of(activityLogDto)); mockMvc.perform(get("/activitylogs/ticket/1")) @@ -113,7 +125,9 @@ void testGetActivityLogsBySupportTicketId() throws Exception { .andExpect(jsonPath("$[0].activityType").value("TICKET_CREATED")) .andExpect(jsonPath("$[0].description").value("Test description")) .andExpect(jsonPath("$[0].userId").value(1)) - .andExpect(jsonPath("$[0].supportTicketId").value(1)); + .andExpect(jsonPath("$[0].username").value("alice")) + .andExpect(jsonPath("$[0].supportTicketId").value(1)) + .andExpect(jsonPath("$[0].ticketTitle").value("VPN access issue")); } @Test @@ -128,6 +142,19 @@ void testGetActivityLogsByUserId() throws Exception { .andExpect(jsonPath("$[0].activityType").value("TICKET_CREATED")) .andExpect(jsonPath("$[0].description").value("Test description")) .andExpect(jsonPath("$[0].userId").value(1)) - .andExpect(jsonPath("$[0].supportTicketId").value(1)); + .andExpect(jsonPath("$[0].username").value("alice")) + .andExpect(jsonPath("$[0].supportTicketId").value(1)) + .andExpect(jsonPath("$[0].ticketTitle").value("VPN access issue")); + } + + @Test + @WithMockUser + void testGetActivityLogsBySupportTicketIdShouldReturnNotFoundWhenTicketDoesNotExist() throws Exception { + when(activityLogService.getActivityLogsBySupportTicketId(999L, "desc")) + .thenThrow(new ResourceNotFoundException("Support ticket not found with id: 999")); + + mockMvc.perform(get("/activitylogs/ticket/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Support ticket not found with id: 999")); } -} \ No newline at end of file +} diff --git a/src/test/java/org/group1/projectbackend/controller/CommentControllerTest.java b/src/test/java/org/group1/projectbackend/controller/CommentControllerTest.java index 547e06a..31d035d 100644 --- a/src/test/java/org/group1/projectbackend/controller/CommentControllerTest.java +++ b/src/test/java/org/group1/projectbackend/controller/CommentControllerTest.java @@ -1,7 +1,6 @@ package org.group1.projectbackend.controller; import org.group1.projectbackend.dto.comment.CommentDto; -import org.group1.projectbackend.service.CommentService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -25,7 +24,7 @@ class CommentControllerTest { private MockMvc mockMvc; @MockitoBean - private CommentService commentService; + private org.group1.projectbackend.service.CommentService commentService; private CommentDto commentDto; @@ -108,4 +107,4 @@ void testDeleteComment() throws Exception { .with(csrf())) .andExpect(status().isNoContent()); } -} \ No newline at end of file +} diff --git a/src/test/java/org/group1/projectbackend/controller/DocumentControllerTest.java b/src/test/java/org/group1/projectbackend/controller/DocumentControllerTest.java index 2a0e38c..917a507 100644 --- a/src/test/java/org/group1/projectbackend/controller/DocumentControllerTest.java +++ b/src/test/java/org/group1/projectbackend/controller/DocumentControllerTest.java @@ -165,7 +165,7 @@ void shouldDownloadDocument() throws Exception { @Test @WithMockUser void shouldDeleteDocument() throws Exception { - doNothing().when(documentService).deleteDocument(100L); + doNothing().when(documentService).deleteDocument(any(), eq(100L)); mockMvc.perform(delete("/api/documents/100") .with(csrf())) diff --git a/src/test/java/org/group1/projectbackend/controller/SupportTicketControllerTest.java b/src/test/java/org/group1/projectbackend/controller/SupportTicketControllerTest.java index 8825111..9e122c4 100644 --- a/src/test/java/org/group1/projectbackend/controller/SupportTicketControllerTest.java +++ b/src/test/java/org/group1/projectbackend/controller/SupportTicketControllerTest.java @@ -111,7 +111,7 @@ void shouldUpdateTicketStatus() throws Exception { "alice" ); - when(supportTicketService.updateStatus(any(), any())).thenReturn(updated); + when(supportTicketService.updateStatus(any(), any(), any())).thenReturn(updated); mockMvc.perform(put("/api/tickets/10/status") .with(SecurityMockMvcRequestPostProcessors.csrf()) diff --git a/src/test/java/org/group1/projectbackend/mapper/ActivityLogMapperTest.java b/src/test/java/org/group1/projectbackend/mapper/ActivityLogMapperTest.java index 2b897e7..c82b51f 100644 --- a/src/test/java/org/group1/projectbackend/mapper/ActivityLogMapperTest.java +++ b/src/test/java/org/group1/projectbackend/mapper/ActivityLogMapperTest.java @@ -26,9 +26,11 @@ void setUp() { void toDtoShouldReturnActivityLogDtoWhenActivityLogIsNotNull() { User user = new User(); user.setId(1L); + user.setUsername("alice"); SupportTicket ticket = new SupportTicket(); ticket.setId(10L); + ticket.setTitle("VPN access issue"); LocalDateTime now = LocalDateTime.now(); @@ -47,7 +49,9 @@ void toDtoShouldReturnActivityLogDtoWhenActivityLogIsNotNull() { assertThat(activityLogDto.getActivityType()).isEqualTo(ActivityType.TICKET_CREATED); assertThat(activityLogDto.getDescription()).isEqualTo("Test description"); assertThat(activityLogDto.getUserId()).isEqualTo(1L); + assertThat(activityLogDto.getUsername()).isEqualTo("alice"); assertThat(activityLogDto.getSupportTicketId()).isEqualTo(10L); + assertThat(activityLogDto.getTicketTitle()).isEqualTo("VPN access issue"); assertThat(activityLogDto.getCreatedAt()).isEqualTo(now); } @@ -100,6 +104,8 @@ void toDtoShouldHandleNullUserAndTicket() { ActivityLogDto dto = activityLogMapper.toDto(activityLog); assertThat(dto.getUserId()).isNull(); + assertThat(dto.getUsername()).isNull(); assertThat(dto.getSupportTicketId()).isNull(); + assertThat(dto.getTicketTitle()).isNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/org/group1/projectbackend/service/ActivityLogServiceTest.java b/src/test/java/org/group1/projectbackend/service/ActivityLogServiceTest.java index f804129..52a38e3 100644 --- a/src/test/java/org/group1/projectbackend/service/ActivityLogServiceTest.java +++ b/src/test/java/org/group1/projectbackend/service/ActivityLogServiceTest.java @@ -6,6 +6,7 @@ import org.group1.projectbackend.entity.SupportTicket; import org.group1.projectbackend.entity.User; import org.group1.projectbackend.entity.enums.ActivityType; +import org.group1.projectbackend.exception.ResourceNotFoundException; import org.group1.projectbackend.mapper.ActivityLogMapper; import org.group1.projectbackend.repository.ActivityLogRepository; import org.group1.projectbackend.repository.SupportTicketRepository; @@ -57,9 +58,11 @@ public class ActivityLogServiceTest { void setUp() { user = new User(); user.setId(1L); + user.setUsername("alice"); supportTicket = new SupportTicket(); supportTicket.setId(10L); + supportTicket.setTitle("VPN access issue"); activityLog = new ActivityLog(); activityLog.setId(100L); @@ -71,10 +74,12 @@ void setUp() { activityLogDto = new ActivityLogDto( 100L, - ActivityType.TICKET_CREATED, - "Test activity log", 1L, + "alice", 10L, + "VPN access issue", + ActivityType.TICKET_CREATED, + "Test activity log", activityLog.getCreatedAt() ); @@ -99,6 +104,8 @@ void shouldCreateActivityLogWhenDtoIsValid() { assertThat(result).isNotNull(); assertThat(result.getDescription()).isEqualTo("Test activity log"); assertThat(result.getActivityType()).isEqualTo(ActivityType.TICKET_CREATED); + assertThat(result.getUsername()).isEqualTo("alice"); + assertThat(result.getTicketTitle()).isEqualTo("VPN access issue"); verify(activityLogRepository).save(activityLog); } @@ -121,10 +128,12 @@ void shouldCreateActivityLogWithoutSupportTicketWhenSupportTicketIdIsNull() { ActivityLogDto activityLogDtoWithoutSupportTicket = new ActivityLogDto( 101L, - ActivityType.TICKET_CREATED, - "Test activity log", 1L, + "alice", + null, null, + ActivityType.TICKET_CREATED, + "Test activity log", activityLogWithoutSupportTicket.getCreatedAt() ); @@ -137,6 +146,8 @@ void shouldCreateActivityLogWithoutSupportTicketWhenSupportTicketIdIsNull() { assertThat(result).isNotNull(); assertThat(result.getSupportTicketId()).isNull(); + assertThat(result.getUsername()).isEqualTo("alice"); + assertThat(result.getTicketTitle()).isNull(); assertThat(result.getDescription()).isEqualTo("Test activity log"); verify(activityLogRepository).save(activityLogWithoutSupportTicket); } @@ -150,6 +161,8 @@ void shouldReturnAllActivityLogs() { assertThat(result).hasSize(1); assertThat(result.get(0).getDescription()).isEqualTo("Test activity log"); + assertThat(result.get(0).getUsername()).isEqualTo("alice"); + assertThat(result.get(0).getTicketTitle()).isEqualTo("VPN access issue"); verify(activityLogRepository).findAll(); } @@ -163,11 +176,14 @@ void shouldReturnActivityLogById() { assertThat(result).isNotNull(); assertThat(result.getId()).isEqualTo(100L); assertThat(result.getDescription()).isEqualTo("Test activity log"); + assertThat(result.getUsername()).isEqualTo("alice"); + assertThat(result.getTicketTitle()).isEqualTo("VPN access issue"); verify(activityLogRepository).findById(100L); } @Test void shouldReturnActivityLogsBySupportTicketId() { + when(supportTicketRepository.existsById(10L)).thenReturn(true); when(activityLogRepository.findBySupportTicketId(eq(10L), any(Sort.class))) .thenReturn(List.of(activityLog)); when(activityLogMapper.toDto(activityLog)).thenReturn(activityLogDto); @@ -178,9 +194,19 @@ void shouldReturnActivityLogsBySupportTicketId() { assertThat(result).hasSize(1); assertThat(result.get(0).getSupportTicketId()).isEqualTo(10L); assertThat(result.get(0).getDescription()).isEqualTo("Test activity log"); + assertThat(result.get(0).getTicketTitle()).isEqualTo("VPN access issue"); verify(activityLogRepository).findBySupportTicketId(eq(10L), any(Sort.class)); } + @Test + void shouldThrowExceptionWhenSupportTicketDoesNotExistForTicketHistory() { + when(supportTicketRepository.existsById(999L)).thenReturn(false); + + assertThatThrownBy(() -> activityLogService.getActivityLogsBySupportTicketId(999L, "desc")) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessage("Support ticket not found with id: 999"); + } + @Test void shouldReturnActivityLogsByUserId() { when(activityLogRepository.findByUserId(eq(1L), any(Sort.class))) @@ -193,6 +219,7 @@ void shouldReturnActivityLogsByUserId() { assertThat(result).hasSize(1); assertThat(result.get(0).getUserId()).isEqualTo(1L); assertThat(result.get(0).getDescription()).isEqualTo("Test activity log"); + assertThat(result.get(0).getUsername()).isEqualTo("alice"); verify(activityLogRepository).findByUserId(eq(1L), any(Sort.class)); } @@ -201,7 +228,7 @@ void shouldThrowExceptionWhenUserDoesNotExist() { when(userRepository.findById(1L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> activityLogService.createActivityLog(createActivityLogDto)) - .isInstanceOf(RuntimeException.class) + .isInstanceOf(ResourceNotFoundException.class) .hasMessage("User not found with id: 1"); } @@ -211,7 +238,7 @@ void shouldThrowExceptionWhenSupportTicketDoesNotExist() { when(supportTicketRepository.findById(10L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> activityLogService.createActivityLog(createActivityLogDto)) - .isInstanceOf(RuntimeException.class) + .isInstanceOf(ResourceNotFoundException.class) .hasMessage("Support ticket not found with id: 10"); } @@ -220,7 +247,7 @@ void shouldThrowExceptionWhenActivityLogDoesNotExist() { when(activityLogRepository.findById(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> activityLogService.getActivityLogById(999L)) - .isInstanceOf(RuntimeException.class) + .isInstanceOf(ResourceNotFoundException.class) .hasMessage("Activity log not found with id: 999"); } -} \ No newline at end of file +} diff --git a/src/test/java/org/group1/projectbackend/service/CommentServiceTest.java b/src/test/java/org/group1/projectbackend/service/CommentServiceTest.java index e48680c..e5bdf07 100644 --- a/src/test/java/org/group1/projectbackend/service/CommentServiceTest.java +++ b/src/test/java/org/group1/projectbackend/service/CommentServiceTest.java @@ -46,7 +46,7 @@ public class CommentServiceTest { private ActivityLogService activityLogService; @InjectMocks - private CommentService commentService; + private org.group1.projectbackend.service.CommentService commentService; private Comment comment; private CommentDto commentDto; @@ -157,6 +157,41 @@ void shouldUpdateCommentWhenContentIsValid() { verify(commentMapper).updateEntity(updateCommentDto, comment); } + @Test + void shouldDeleteCommentEvenWhenActivityLogFails() { + when(commentRepository.findById(100L)).thenReturn(Optional.of(comment)); + when(activityLogService.createActivityLog(any())) + .thenThrow(new RuntimeException("Activity log failed")); + + commentService.deleteComment(100L); + + verify(commentRepository).delete(comment); + } + + @Test + void shouldUpdateCommentEvenWhenActivityLogFails() { + CommentDto updatedCommentDto = new CommentDto( + 100L, + "Updated comment", + 1L, + "Testname", + 10L, + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + + when(commentRepository.findById(100L)).thenReturn(Optional.of(comment)); + when(commentRepository.save(comment)).thenReturn(comment); + when(commentMapper.toDto(comment)).thenReturn(updatedCommentDto); + when(activityLogService.createActivityLog(any())) + .thenThrow(new RuntimeException("Activity log failed")); + + CommentDto result = commentService.updateComment(100L, updateCommentDto); + + assertThat(result.getContent()).isEqualTo("Updated comment"); + verify(commentMapper).updateEntity(updateCommentDto, comment); + } + @Test void shouldThrowExceptionWhenDeletingNonExistingComment() { when(commentRepository.findById(999L)).thenReturn(Optional.empty()); @@ -165,4 +200,4 @@ void shouldThrowExceptionWhenDeletingNonExistingComment() { .isInstanceOf(RuntimeException.class) .hasMessage("Comment not found with id: 999"); } -} \ No newline at end of file +} diff --git a/src/test/java/org/group1/projectbackend/service/DocumentServiceTest.java b/src/test/java/org/group1/projectbackend/service/DocumentServiceTest.java index fb323f4..88e5ecf 100644 --- a/src/test/java/org/group1/projectbackend/service/DocumentServiceTest.java +++ b/src/test/java/org/group1/projectbackend/service/DocumentServiceTest.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import org.group1.projectbackend.config.S3StorageProperties; +import org.group1.projectbackend.entity.enums.ActivityType; import org.group1.projectbackend.dto.document.DocumentDownloadResponse; import org.group1.projectbackend.dto.document.DocumentResponse; import org.group1.projectbackend.entity.Document; @@ -14,6 +15,7 @@ import org.group1.projectbackend.repository.DocumentRepository; import org.group1.projectbackend.repository.SupportTicketRepository; import org.group1.projectbackend.repository.UserRepository; +import org.group1.projectbackend.service.ActivityLogService; import org.group1.projectbackend.service.impl.DocumentServiceImpl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,6 +31,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -52,6 +55,9 @@ class DocumentServiceTest { @Mock private S3StorageProperties s3StorageProperties; + @Mock + private ActivityLogService activityLogService; + @InjectMocks private DocumentServiceImpl documentService; @@ -114,6 +120,12 @@ void shouldUploadDocumentForTicket() { assertThat(response.ticketId()).isEqualTo(10L); assertThat(response.uploadedById()).isEqualTo(1L); verify(objectStorageService).upload(anyString(), anyString(), anyLong(), any()); + verify(activityLogService).createActivityLog(argThat(dto -> + dto.getActivityType() == ActivityType.FILE_UPLOADED + && "File uploaded: guide.pdf".equals(dto.getDescription()) + && dto.getUserId().equals(1L) + && dto.getSupportTicketId().equals(10L) + )); } @Test @@ -167,12 +179,19 @@ void shouldDownloadDocument() throws Exception { @Test void shouldDeleteDocument() { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); when(documentRepository.findById(100L)).thenReturn(Optional.of(document)); - documentService.deleteDocument(100L); + documentService.deleteDocument("testuser", 100L); InOrder inOrder = inOrder(documentRepository, objectStorageService); inOrder.verify(documentRepository).delete(document); inOrder.verify(objectStorageService).delete("tickets/10/test-guide.pdf"); + verify(activityLogService).createActivityLog(argThat(dto -> + dto.getActivityType() == ActivityType.FILE_DELETED + && "File deleted: guide.pdf".equals(dto.getDescription()) + && dto.getUserId().equals(1L) + && dto.getSupportTicketId().equals(10L) + )); } } diff --git a/src/test/java/org/group1/projectbackend/service/SupportTicketServiceTest.java b/src/test/java/org/group1/projectbackend/service/SupportTicketServiceTest.java index 40c53c6..6d07787 100644 --- a/src/test/java/org/group1/projectbackend/service/SupportTicketServiceTest.java +++ b/src/test/java/org/group1/projectbackend/service/SupportTicketServiceTest.java @@ -6,6 +6,7 @@ import org.group1.projectbackend.dto.ticket.CreateTicketRequest; import org.group1.projectbackend.dto.ticket.TicketResponse; import org.group1.projectbackend.dto.ticket.UpdateTicketStatusRequest; +import org.group1.projectbackend.entity.enums.ActivityType; import org.group1.projectbackend.entity.SupportTicket; import org.group1.projectbackend.entity.User; import org.group1.projectbackend.entity.enums.TicketPriority; @@ -13,6 +14,7 @@ import org.group1.projectbackend.exception.ResourceNotFoundException; import org.group1.projectbackend.repository.SupportTicketRepository; import org.group1.projectbackend.repository.UserRepository; +import org.group1.projectbackend.service.ActivityLogService; import org.group1.projectbackend.service.impl.SupportTicketServiceImpl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -36,6 +39,9 @@ class SupportTicketServiceTest { @Mock private UserRepository userRepository; + @Mock + private ActivityLogService activityLogService; + @InjectMocks private SupportTicketServiceImpl supportTicketService; @@ -78,6 +84,12 @@ void shouldCreateTicket() { assertThat(response.priority()).isEqualTo(TicketPriority.HIGH); assertThat(response.createdById()).isEqualTo(1L); verify(supportTicketRepository).save(any(SupportTicket.class)); + verify(activityLogService).createActivityLog(argThat(dto -> + dto.getActivityType() == ActivityType.TICKET_CREATED + && "Ticket created".equals(dto.getDescription()) + && dto.getUserId().equals(1L) + && dto.getSupportTicketId().equals(10L) + )); } @Test @@ -104,13 +116,20 @@ void shouldListTicketsForUser() { @Test void shouldUpdateTicketStatus() { UpdateTicketStatusRequest request = new UpdateTicketStatusRequest(TicketStatus.RESOLVED); + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); when(supportTicketRepository.findById(10L)).thenReturn(Optional.of(ticket)); when(supportTicketRepository.save(ticket)).thenReturn(ticket); - TicketResponse response = supportTicketService.updateStatus(10L, request); + TicketResponse response = supportTicketService.updateStatus("alice", 10L, request); assertThat(response.status()).isEqualTo(TicketStatus.RESOLVED); verify(supportTicketRepository).save(ticket); + verify(activityLogService).createActivityLog(argThat(dto -> + dto.getActivityType() == ActivityType.TICKET_STATUS_CHANGED + && "Ticket status changed from OPEN to RESOLVED".equals(dto.getDescription()) + && dto.getUserId().equals(1L) + && dto.getSupportTicketId().equals(10L) + )); } @Test @@ -124,9 +143,10 @@ void shouldThrowWhenTicketIsNotFoundById() { @Test void shouldThrowWhenUpdatingMissingTicket() { + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); when(supportTicketRepository.findById(999L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> supportTicketService.updateStatus(999L, new UpdateTicketStatusRequest(TicketStatus.CLOSED))) + assertThatThrownBy(() -> supportTicketService.updateStatus("alice", 999L, new UpdateTicketStatusRequest(TicketStatus.CLOSED))) .isInstanceOf(ResourceNotFoundException.class) .hasMessage("Ticket not found with id: 999"); } From e20f3522187395e944f5fb0a12baf5b29f0cf287 Mon Sep 17 00:00:00 2001 From: Adam Majava Date: Fri, 24 Apr 2026 15:04:20 +0200 Subject: [PATCH 2/2] feat: add activity log view with filtering options and UI enhancements --- .../web/ActivityLogViewController.java | 71 ++++++++++ src/main/resources/static/css/app.css | 45 +++++- .../templates/activitylogs/list.html | 133 ++++++++++++++++++ .../resources/templates/documents/list.html | 4 +- .../resources/templates/fragments/layout.html | 1 + .../resources/templates/tickets/detail.html | 42 ++++-- .../resources/templates/tickets/list.html | 7 +- 7 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/group1/projectbackend/controller/web/ActivityLogViewController.java create mode 100644 src/main/resources/templates/activitylogs/list.html diff --git a/src/main/java/org/group1/projectbackend/controller/web/ActivityLogViewController.java b/src/main/java/org/group1/projectbackend/controller/web/ActivityLogViewController.java new file mode 100644 index 0000000..6e94c05 --- /dev/null +++ b/src/main/java/org/group1/projectbackend/controller/web/ActivityLogViewController.java @@ -0,0 +1,71 @@ +package org.group1.projectbackend.controller.web; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import org.group1.projectbackend.dto.activitylog.ActivityLogDto; +import org.group1.projectbackend.entity.enums.ActivityType; +import org.group1.projectbackend.service.ActivityLogService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class ActivityLogViewController { + + private final ActivityLogService activityLogService; + + public ActivityLogViewController(ActivityLogService activityLogService) { + this.activityLogService = activityLogService; + } + + @GetMapping("/activitylogs/view") + public String listActivityLogs( + @RequestParam(required = false) ActivityType type, + @RequestParam(required = false) String username, + @RequestParam(required = false) String ticket, + @RequestParam(required = false) LocalDate date, + Model model + ) { + String normalizedUsername = normalizeFilterValue(username); + String normalizedTicket = normalizeFilterValue(ticket); + + List activityLogs = activityLogService.getAllActivityLogs().stream() + .sorted((left, right) -> right.getCreatedAt().compareTo(left.getCreatedAt())) + .filter(activityLog -> type == null || activityLog.getActivityType() == type) + .filter(activityLog -> normalizedUsername == null || containsIgnoreCase(activityLog.getUsername(), normalizedUsername)) + .filter(activityLog -> normalizedTicket == null + || containsIgnoreCase(activityLog.getTicketTitle(), normalizedTicket) + || matchesTicketId(activityLog, normalizedTicket)) + .filter(activityLog -> date == null + || (activityLog.getCreatedAt() != null && activityLog.getCreatedAt().toLocalDate().isEqual(date))) + .toList(); + + model.addAttribute("activityLogs", activityLogs); + model.addAttribute("activityTypes", Arrays.asList(ActivityType.values())); + model.addAttribute("selectedType", type); + model.addAttribute("selectedUsername", username); + model.addAttribute("selectedTicket", ticket); + model.addAttribute("selectedDate", date); + + return "activitylogs/list"; + } + + private String normalizeFilterValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + + return value.trim(); + } + + private boolean containsIgnoreCase(String source, String expected) { + return source != null && source.toLowerCase().contains(expected.toLowerCase()); + } + + private boolean matchesTicketId(ActivityLogDto activityLog, String expected) { + return activityLog.getSupportTicketId() != null + && String.valueOf(activityLog.getSupportTicketId()).contains(expected); + } +} diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index e52af6c..dc0941e 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -99,7 +99,7 @@ textarea:focus-visible { .site-header, main { - width: min(1120px, calc(100% - 2rem)); + width: min(1200px, calc(100% - 2rem)); margin-inline: auto; } @@ -140,7 +140,7 @@ main { .site-nav { display: flex; flex-wrap: wrap; - gap: 0.55rem; + gap: 1rem; justify-content: flex-end; } @@ -149,7 +149,7 @@ main { border-radius: 999px; background: rgba(255, 255, 255, 0.045); color: var(--foreground-subtle); - font-size: 0.88rem; + font-size: 1rem; font-weight: 600; padding: 0.55rem 0.85rem; transition: transform 220ms var(--ease-out-expo), border-color 220ms var(--ease-out-expo), background 220ms var(--ease-out-expo), color 220ms var(--ease-out-expo); @@ -267,7 +267,7 @@ button { font: inherit; font-size: 0.95rem; font-weight: 700; - padding: 0.78rem 1rem; + padding: 10px 20px; transition: transform 220ms var(--ease-out-expo), background 220ms var(--ease-out-expo), box-shadow 220ms var(--ease-out-expo); } @@ -327,6 +327,14 @@ dl { margin-top: 2rem; } +.table-action-form { + display: block; + border: 0; + background: transparent; + box-shadow: none; + padding: 0; +} + table { width: 100%; min-width: 720px; @@ -352,7 +360,7 @@ th { } td { - font-size: 1.2rem; + font-size: 1rem; color: var(--foreground-subtle); } @@ -405,10 +413,12 @@ dd { section { padding: 1.15rem; + } -section-wide { +.section-wide { padding: 1.15rem; + margin-top: 2rem; } form { @@ -454,6 +464,12 @@ textarea:focus { box-shadow: 0 0 0 4px rgba(94, 106, 210, 0.16); } +.is-active-filter { + border-color: rgba(94, 106, 210, 0.72); + background: rgba(94, 106, 210, 0.10); + box-shadow: 0 0 0 1px rgba(94, 106, 210, 0.16); +} + textarea { min-height: 8.5rem; resize: vertical; @@ -470,7 +486,7 @@ textarea { font-size: 1rem; font-weight: 700; letter-spacing: 0.08em; - padding: 0.55rem 0.55rem; + padding: 5px 10px; text-transform: uppercase; } @@ -609,6 +625,16 @@ main a:hover { color: var(--foreground); } +.filter-form { + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: end; +} + +.filter-form .actions { + align-self: end; + margin-top: 0; +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -635,6 +661,11 @@ main a:hover { grid-template-columns: 1fr; } + .filter-form { + grid-template-columns: 1fr; + align-items: stretch; + } + dl { grid-template-columns: 1fr; } diff --git a/src/main/resources/templates/activitylogs/list.html b/src/main/resources/templates/activitylogs/list.html new file mode 100644 index 0000000..ed79e6d --- /dev/null +++ b/src/main/resources/templates/activitylogs/list.html @@ -0,0 +1,133 @@ + + + + + + Activity Log + + + +
+ +
+
+

Activity Log

+

All historik samlad på ett ställe.

+

+ Filtrera på aktivitetstyp och följ ticket-, kommentar- och dokumenthändelser + med senaste ändringen överst. +

+
+ +
+

Filtrera loggar

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Rensa filter +
+
+
+ +

+ Det finns inga aktivitetsloggar att visa för det valda filtret. +

+ +

+ Aktiva filter: + Typ: FILE_UPLOADED + , + Användare: alice + , + Ticket: 42 + , + Datum: 2026-04-24 +

+ +

+ Inga filter aktiva. Visar hela historiken. +

+ +

+ 3 loggrader matchar filtret. +

+ +
+ + + + + + + + + + + + + + + + + + + +
TypBeskrivningAnvändareTicketSkapad
+ TICKET_CREATED + + Ticket created + alice + + VPN access issue + + Ingen ticket + 2026-04-24 12:00
+
+
+ + diff --git a/src/main/resources/templates/documents/list.html b/src/main/resources/templates/documents/list.html index 3b6e7df..a6cdb7c 100644 --- a/src/main/resources/templates/documents/list.html +++ b/src/main/resources/templates/documents/list.html @@ -55,7 +55,9 @@

Alla uppladdade filer i systemet.

Ladda ner -
+
diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index fb927ea..200080d 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -11,6 +11,7 @@

Internt ärendehanteringssystem

Ticket-lista Skapa ticket Dokument + Activity Log diff --git a/src/main/resources/templates/tickets/detail.html b/src/main/resources/templates/tickets/detail.html index aa1491e..6571325 100644 --- a/src/main/resources/templates/tickets/detail.html +++ b/src/main/resources/templates/tickets/detail.html @@ -10,17 +10,35 @@
-
-

Ticket

-

Ticket-rubrik

-

Beskrivning

-
- OPEN - HIGH -
-
-
+
+

Ticket

+

+ Ticket-rubrik +

+

Beskrivning

+
+ OPEN + HIGH +
+
+ +
+

Detaljer

+

Snabb överblick över ticket innehåll och aktivitet.

+
+
Kommentarer
+
0
+ +
Händelser
+
0
+ +
Uppladdade filer
+
0
+
+
+
ID
1
@@ -64,7 +82,7 @@

Uppdatera status

-
+

Kommentarer

@@ -90,7 +108,7 @@

Kommentarer

-
+

Historik

Det finns ingen aktivitetshistorik för denna ticket ännu.

diff --git a/src/main/resources/templates/tickets/list.html b/src/main/resources/templates/tickets/list.html index ac35150..a20d368 100644 --- a/src/main/resources/templates/tickets/list.html +++ b/src/main/resources/templates/tickets/list.html @@ -36,12 +36,15 @@

Alla ärenden i ett tydligt arbetsflöde.

1 - Exempelticket + + Exempelticket + OPEN HIGH 2026-04-22T12:00 - Visa +