Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -86,4 +89,9 @@ public User findById(Long id) {
return userRep.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
}

public Map<Long, String> findAllByIds(Collection<Long> ids) {
return userRep.findAllById(ids).stream()
.collect(Collectors.toMap(User::getId, User::getUsername));
}
}
9 changes: 8 additions & 1 deletion src/main/java/org/example/untitled/usercase/AuditAction.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package org.example.untitled.usercase;

import java.util.Locale;

public enum AuditAction {
CASE_CREATED,
CASE_ASSIGNED,
CASE_STATUS_CHANGED,
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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<CommentDto> comments = commentService.getCommentsByTicketId(id);
List<AuditLog> auditLogs = auditLogService.getLogsForCase(id);
Map<Long, String> 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";
}

Expand Down Expand Up @@ -120,12 +152,14 @@ public ResponseEntity<CaseEntityDto> 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";
}
Expand All @@ -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")
Expand Down Expand Up @@ -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<AuditLog> auditLogs = auditLogService.getLogsForCase(id);
Map<Long, String> 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";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// --- 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<Long, String> buildAuditUserMap(List<AuditLog> auditLogs) {
Set<Long> userIds = auditLogs.stream()
.map(AuditLog::getUserId)
.filter(uid -> uid != null)
.collect(Collectors.toSet());
if (userIds.isEmpty()) {
return Map.of();
}
return userService.findAllByIds(userIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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<String> 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));
}
Expand All @@ -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<CaseEntityDto> getAllTickets() {
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<CommentDto> getCommentsByTicketId(Long id) {
Expand Down
Loading