diff --git a/src/main/java/org/example/untitled/user/service/UserService.java b/src/main/java/org/example/untitled/user/service/UserService.java index b90dc72..ef7b319 100644 --- a/src/main/java/org/example/untitled/user/service/UserService.java +++ b/src/main/java/org/example/untitled/user/service/UserService.java @@ -17,7 +17,10 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service public class UserService { @@ -86,4 +89,9 @@ public User findById(Long id) { return userRep.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); } + + public Map findAllByIds(Collection ids) { + return userRep.findAllById(ids).stream() + .collect(Collectors.toMap(User::getId, User::getUsername)); + } } diff --git a/src/main/java/org/example/untitled/usercase/AuditAction.java b/src/main/java/org/example/untitled/usercase/AuditAction.java index 51b38ae..79ffe53 100644 --- a/src/main/java/org/example/untitled/usercase/AuditAction.java +++ b/src/main/java/org/example/untitled/usercase/AuditAction.java @@ -1,5 +1,7 @@ package org.example.untitled.usercase; +import java.util.Locale; + public enum AuditAction { CASE_CREATED, CASE_ASSIGNED, @@ -7,5 +9,10 @@ public enum AuditAction { COMMENT_ADDED, FILE_UPLOADED, USER_ROLE_CHANGED, - CASE_UPDATED + CASE_UPDATED; + + public String getDisplayName() { + String name = this.name().toLowerCase(Locale.ROOT).replace('_', ' '); + return Character.toUpperCase(name.charAt(0)) + name.substring(1); + } } diff --git a/src/main/java/org/example/untitled/usercase/controller/CaseController.java b/src/main/java/org/example/untitled/usercase/controller/CaseController.java index e600666..9d9737d 100644 --- a/src/main/java/org/example/untitled/usercase/controller/CaseController.java +++ b/src/main/java/org/example/untitled/usercase/controller/CaseController.java @@ -2,17 +2,19 @@ import jakarta.validation.Valid; import org.example.untitled.user.Role; +import org.example.untitled.user.service.UserService; +import org.example.untitled.usercase.AuditLog; import org.example.untitled.usercase.CaseStatus; import org.example.untitled.usercase.dto.CaseEntityDto; import org.example.untitled.usercase.dto.CommentDto; import org.example.untitled.usercase.dto.CreateCaseRequest; import org.example.untitled.usercase.dto.CreateCommentRequest; -import org.example.untitled.usercase.AuditLog; import org.example.untitled.usercase.service.AuditLogService; import org.example.untitled.usercase.service.CaseService; import org.example.untitled.usercase.service.CommentService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -26,7 +28,10 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; @Controller @RequestMapping("/tickets") @@ -35,13 +40,38 @@ public class CaseController { private final CaseService caseService; private final CommentService commentService; private final AuditLogService auditLogService; + private final UserService userService; private static final Logger log = LoggerFactory.getLogger(CaseController.class); - - public CaseController(CaseService caseService, CommentService commentService, AuditLogService auditLogService) { + public CaseController(CaseService caseService, CommentService commentService, + AuditLogService auditLogService, UserService userService) { this.caseService = caseService; this.commentService = commentService; this.auditLogService = auditLogService; + this.userService = userService; + } + + /** + * Encapsulates permission checks for a given ticket and user so the + * same logic is not duplicated across multiple handler methods. + */ + private record TicketPermissions( + boolean isHandler, + boolean isOwner, + boolean isAssigned, + boolean canClose, + boolean canComment + ) { + static TicketPermissions of(CaseEntityDto ticket, UserDetails userDetails, + boolean isHandler) { + boolean isOwner = ticket.ownerUsername().equals(userDetails.getUsername()); + boolean isAssigned = userDetails.getUsername().equals(ticket.assignedToUsername()); + boolean isTicketOpen = ticket.status() != CaseStatus.CLOSED + && ticket.status() != CaseStatus.SOLVED; + boolean canClose = isTicketOpen && (isOwner || isAssigned || isHandler); + boolean canComment = isOwner || isAssigned || isHandler; + return new TicketPermissions(isHandler, isOwner, isAssigned, canClose, canComment); + } } @PostMapping @@ -65,20 +95,22 @@ public String showTicketDetails( Model model) { CaseEntityDto ticket = caseService.getTicketByID(id); + TicketPermissions perms = TicketPermissions.of(ticket, userDetails, isHandlerOrAbove(userDetails)); - boolean isHandler = userDetails.getAuthorities().stream() - .map(a -> Role.fromAuthority(a.getAuthority())) - .flatMap(Optional::stream) - .anyMatch(r -> r == Role.HANDLER || r == Role.SUPERVISOR || r == Role.ADMIN); - - if (!isHandler && caseService.isNotOwner(ticket, userDetails.getUsername())) + if (!perms.isHandler() && caseService.isNotOwner(ticket, userDetails.getUsername())) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not own this ticket"); List comments = commentService.getCommentsByTicketId(id); List auditLogs = auditLogService.getLogsForCase(id); + Map auditUserMap = buildAuditUserMap(auditLogs); + model.addAttribute("ticket", ticket); model.addAttribute("comments", comments); + model.addAttribute("comment", new CreateCommentRequest()); + model.addAttribute("canClose", perms.canClose()); + model.addAttribute("canComment", perms.canComment()); model.addAttribute("auditLogs", auditLogs); + model.addAttribute("auditUserMap", auditUserMap); return "ticket"; } @@ -120,12 +152,14 @@ public ResponseEntity assignToSelf( public String closeTicket( Model model, @PathVariable long id, - @AuthenticationPrincipal UserDetails userDetails - ) { + @AuthenticationPrincipal UserDetails userDetails) { CaseEntityDto ticket = caseService.getTicketByID(id); - if (caseService.isNotOwner(ticket, userDetails.getUsername())) - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not own this ticket"); - model.addAttribute("ticket", caseService.getTicketByID(id)); + TicketPermissions perms = TicketPermissions.of(ticket, userDetails, isHandlerOrAbove(userDetails)); + + if (!perms.isHandler() && !perms.isAssigned() && caseService.isNotOwner(ticket, userDetails.getUsername())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You are not allowed to close this ticket"); + + model.addAttribute("ticket", ticket); model.addAttribute("comment", new CreateCommentRequest()); return "close_ticket"; } @@ -136,25 +170,27 @@ public String processCloseTicket( @ModelAttribute("comment") @Valid CreateCommentRequest comment, BindingResult bindingResult, @AuthenticationPrincipal UserDetails userDetails, - Model model - ) { + Model model) { CaseEntityDto ticket = caseService.getTicketByID(id); - if (caseService.isNotOwner(ticket, userDetails.getUsername())) - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not own this ticket"); + TicketPermissions perms = TicketPermissions.of(ticket, userDetails, isHandlerOrAbove(userDetails)); + + if (!perms.isHandler() && !perms.isAssigned() && caseService.isNotOwner(ticket, userDetails.getUsername())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You are not allowed to close this ticket"); + if (bindingResult.hasErrors()) { model.addAttribute("ticket", ticket); return "close_ticket"; } comment.setCaseId(id); try { - caseService.closeTicket(ticket, comment); + caseService.closeTicket(ticket, comment, userDetails.getUsername()); } catch (IllegalArgumentException e) { bindingResult.rejectValue("text", "error.createCommentRequest", e.getMessage()); model.addAttribute("ticket", ticket); return "close_ticket"; } - return "redirect:/user"; + return perms.isHandler() ? "redirect:/handler" : "redirect:/user"; } @PostMapping("/{id}/assign") @@ -218,4 +254,74 @@ public String updateStatusForm( } return "redirect:/handler"; } + + @PostMapping("/{id}/comments") + @PreAuthorize("isAuthenticated()") + public String addComment( + @PathVariable long id, + @Valid @ModelAttribute("comment") CreateCommentRequest comment, + BindingResult bindingResult, + @AuthenticationPrincipal UserDetails userDetails, + Model model, + RedirectAttributes redirectAttributes) { + + CaseEntityDto ticket = caseService.getTicketByID(id); + TicketPermissions perms = TicketPermissions.of(ticket, userDetails, isHandlerOrAbove(userDetails)); + + if (!perms.canComment()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You are not allowed to comment on this ticket"); + } + + if (bindingResult.hasErrors()) { + List auditLogs = auditLogService.getLogsForCase(id); + Map auditUserMap = buildAuditUserMap(auditLogs); + model.addAttribute("ticket", ticket); + model.addAttribute("comments", commentService.getCommentsByTicketId(id)); + model.addAttribute("canClose", perms.canClose()); + model.addAttribute("canComment", perms.canComment()); + model.addAttribute("auditLogs", auditLogs); + model.addAttribute("auditUserMap", auditUserMap); + return "ticket"; + } + + try { + comment.setCaseId(id); + commentService.createComment(comment, ticket, userDetails.getUsername()); + redirectAttributes.addFlashAttribute("success", "Comment added successfully"); + } catch (IllegalArgumentException e) { + log.warn("Comment creation failed for ticket {} by user {}: {}", id, userDetails.getUsername(), e.getMessage()); + redirectAttributes.addFlashAttribute("error", e.getMessage()); + } catch (DataAccessException e) { + log.error("Data access error adding comment to ticket {} by user {}.", id, userDetails.getUsername(), e); + redirectAttributes.addFlashAttribute("error", "Could not save comment, please try again"); + } catch (ResponseStatusException e) { + log.warn("Comment creation rejected for ticket {} by user {}: {}", id, userDetails.getUsername(), e.getReason(), e); + redirectAttributes.addFlashAttribute("error", + e.getReason() != null ? e.getReason() : "Could not add comment"); + } catch (Exception e) { + log.error("Unexpected error adding comment to ticket {} by user {}.", id, userDetails.getUsername(), e); + redirectAttributes.addFlashAttribute("error", "An unexpected error occurred"); + } + return "redirect:/tickets/" + id; + } + + // --- Private helpers --- + + private boolean isHandlerOrAbove(UserDetails userDetails) { + return userDetails.getAuthorities().stream() + .map(a -> Role.fromAuthority(a.getAuthority())) + .flatMap(Optional::stream) + .anyMatch(r -> r == Role.HANDLER || r == Role.SUPERVISOR || r == Role.ADMIN); + } + + private Map buildAuditUserMap(List auditLogs) { + Set userIds = auditLogs.stream() + .map(AuditLog::getUserId) + .filter(uid -> uid != null) + .collect(Collectors.toSet()); + if (userIds.isEmpty()) { + return Map.of(); + } + return userService.findAllByIds(userIds); + } } diff --git a/src/main/java/org/example/untitled/usercase/service/CaseService.java b/src/main/java/org/example/untitled/usercase/service/CaseService.java index 46b5dbc..4918792 100644 --- a/src/main/java/org/example/untitled/usercase/service/CaseService.java +++ b/src/main/java/org/example/untitled/usercase/service/CaseService.java @@ -61,8 +61,8 @@ public CaseEntityDto createTicket(CreateCaseRequest request, String username) { caseEntity.setOwner(owner); caseEntity.setStatus(CaseStatus.OPEN); caseEntity = caseRepository.save(caseEntity); - if (request.getFileNames() != null){ - for(String fName : request.getFileNames()){ + if (request.getFileNames() != null) { + for (String fName : request.getFileNames()) { if (fName == null || fName.isBlank()) continue; caseEntity.getFiles().addAll(s3Service.createFile(caseEntity, fName)); } @@ -96,11 +96,11 @@ public CaseEntityDto updateTicket(Long id, CreateCaseRequest request, String use } caseEntity.setTitle(request.getTitle()); caseEntity.setDescription(request.getDescription()); - if (request.getFileNames() != null){ + if (request.getFileNames() != null) { Set existing = caseEntity.getFiles().stream() .map(UploadedFile::getS3Key) .collect(Collectors.toSet()); - for(String fName : request.getFileNames()){ + for (String fName : request.getFileNames()) { if (fName == null || fName.isBlank() || existing.contains(fName)) continue; caseEntity.getFiles().addAll(s3Service.createFile(caseEntity, fName)); } @@ -114,13 +114,13 @@ public CaseEntityDto updateTicket(Long id, CreateCaseRequest request, String use } @Transactional - public void closeTicket(CaseEntityDto ticket, CreateCommentRequest comment) { + public void closeTicket(CaseEntityDto ticket, CreateCommentRequest comment, String username) { if (comment == null) - throw new IllegalArgumentException("Comment Cant be null"); + throw new IllegalArgumentException("Comment can't be null"); if (ticket == null) - throw new IllegalArgumentException("Ticket Cant be null"); - updateStatus(ticket.id(), CaseStatus.CLOSED, ticket.ownerUsername()); - commentService.createComment(comment, ticket); + throw new IllegalArgumentException("Ticket can't be null"); + updateStatus(ticket.id(), CaseStatus.CLOSED, username); + commentService.createComment(comment, ticket, username); } public List getAllTickets() { @@ -170,6 +170,7 @@ public CaseEntityDto getTicketByID(long id) { () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found: " + id)); return CaseMapper.toDto(caseEntity); } + public User findOwnerById(long id) { return caseRepository.findOwnerById(id); } diff --git a/src/main/java/org/example/untitled/usercase/service/CommentService.java b/src/main/java/org/example/untitled/usercase/service/CommentService.java index 5a14aef..692f8e1 100644 --- a/src/main/java/org/example/untitled/usercase/service/CommentService.java +++ b/src/main/java/org/example/untitled/usercase/service/CommentService.java @@ -1,5 +1,6 @@ package org.example.untitled.usercase.service; +import org.example.untitled.user.repository.UserRepository; import org.example.untitled.usercase.dto.CommentDto; import org.example.untitled.usercase.AuditAction; import org.example.untitled.usercase.dto.CaseEntityDto; @@ -18,27 +19,31 @@ public class CommentService { private final CommentRepository commentRepository; private final CaseRepository caseRepository; private final AuditLogService auditLogService; + private final UserRepository userRepository; - - public CommentService(CommentRepository commentRepository, CaseRepository caseRepository, AuditLogService auditLogService) { + public CommentService(CommentRepository commentRepository, CaseRepository caseRepository, + AuditLogService auditLogService, UserRepository userRepository) { this.commentRepository = commentRepository; this.caseRepository = caseRepository; this.auditLogService = auditLogService; + this.userRepository = userRepository; } @Transactional - public void createComment(CreateCommentRequest comment, CaseEntityDto ticket) { + public void createComment(CreateCommentRequest comment, CaseEntityDto ticket, String username) { if (comment == null) throw new IllegalArgumentException("CreateCommentRequest can't be null"); if (ticket == null) throw new IllegalArgumentException("CaseEntityDTO can't be null"); var caseEntity = caseRepository.findById(ticket.id()) .orElseThrow(() -> new IllegalArgumentException("Ticket not found: " + ticket.id())); + var author = userRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + username)); var entity = CommentMapper.toEntity(comment); - entity.setAuthor(caseEntity.getOwner()); + entity.setAuthor(author); entity.setCaseEntity(caseEntity); commentRepository.save(entity); - auditLogService.log(AuditAction.COMMENT_ADDED, entity.getAuthor().getId(), caseEntity.getId()); // lägg till + auditLogService.log(AuditAction.COMMENT_ADDED, author.getId(), caseEntity.getId()); } public List getCommentsByTicketId(Long id) { diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index 7779dcb..b32c1c8 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -430,3 +430,34 @@ main { margin: 40px auto; padding: 0 20px; } + +/* Comment section */ + +.comment-box { + background: #1e293b; + border: 1px solid #334155; + border-radius: 12px; + padding: 16px 20px; + margin-bottom: 12px; +} + +.comment-author { + font-size: 13px; + color: #64748b; + margin-top: 8px; +} + +.comment-text { + font-size: 14px; + line-height: 1.6; + color: #cbd5e1; +} + +.section-title { + font-size: 18px; + font-weight: 600; + color: #f1f5f9; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid #1e3a5f; +} diff --git a/src/main/resources/templates/ticket.html b/src/main/resources/templates/ticket.html index 8650650..b7ce774 100644 --- a/src/main/resources/templates/ticket.html +++ b/src/main/resources/templates/ticket.html @@ -10,6 +10,10 @@

Ticket Details

+ +
+
+
@@ -26,27 +30,57 @@

Ticket Details

-
-

Comments

-
-

-

Author:

+ +
+

Comments

+
+
+ + +
+
+

+
-
-

Activity Log

-
-
- - + +
+

Add Comment

+
+
+ + + +
+
+ +
+
+
+ + +
+

Activity Log

+
+
+

+

+

- \ No newline at end of file +