diff --git a/src/main/java/org/fmazmz/casemanager/ticket/application/TicketOrchestrator.java b/src/main/java/org/fmazmz/casemanager/ticket/application/TicketOrchestrator.java index 2699a76..f80756b 100644 --- a/src/main/java/org/fmazmz/casemanager/ticket/application/TicketOrchestrator.java +++ b/src/main/java/org/fmazmz/casemanager/ticket/application/TicketOrchestrator.java @@ -2,12 +2,15 @@ import org.fmazmz.casemanager.exception.AccessDeniedException; import org.fmazmz.casemanager.audit.application.AuditLogWriter; +import org.fmazmz.casemanager.ticket.dto.UpdateTicketPriorityRequest; import org.fmazmz.casemanager.ticket.dto.ChangeTicketStatusRequest; import org.fmazmz.casemanager.ticket.dto.TicketCommentRequest; +import org.fmazmz.casemanager.ticket.dto.UpdateTicketRequest; import org.fmazmz.casemanager.ticket.mapper.CommentMapper; import org.fmazmz.casemanager.ticket.mapper.TicketMapper; import org.fmazmz.casemanager.ticket.domain.Comment; import org.fmazmz.casemanager.ticket.domain.CommentVisibility; +import org.fmazmz.casemanager.ticket.domain.Priority; import org.fmazmz.casemanager.ticket.domain.Ticket; import org.fmazmz.casemanager.ticket.domain.TicketAction; import org.fmazmz.casemanager.ticket.domain.TicketStatus; @@ -26,6 +29,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.UUID; @@ -175,6 +179,97 @@ public TicketResponse changeStatus(UUID ticketId, ChangeTicketStatusRequest requ return TicketMapper.toDto(saved, permissionEvaluator.includeInternalComments(actor)); } + @Transactional + public TicketResponse changePriority(UUID ticketId, UpdateTicketPriorityRequest request, UUID actorId) { + User actor = userLookupService.requireActor(actorId); + + if (!permissionEvaluator.hasPermission(actor, TicketAction.CHANGE_PRIORITY)) { + throw new AccessDeniedException("User is not authorized to perform action: " + TicketAction.CHANGE_PRIORITY); + } + + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); + + Priority fromPriority = ticket.getPriority(); + Priority toPriority = request.priority(); + + if (fromPriority == toPriority) { + return TicketMapper.toDto(ticket, permissionEvaluator.includeInternalComments(actor)); + } + + Comment internalComment = new Comment(); + internalComment.setTicket(ticket); + internalComment.setUser(actor); + internalComment.setVisibility(CommentVisibility.INTERNAL); + internalComment.setMessage(request.internalComment().trim()); + commentRepository.save(internalComment); + + ticket.setPriority(toPriority); + Ticket saved = ticketRepository.saveAndFlush(ticket); + + auditLogWriter.logChange( + saved, + actor, + TicketAction.CHANGE_PRIORITY, + "priority", + fromPriority != null ? fromPriority.name() : null, + toPriority.name() + ); + + return TicketMapper.toDto(saved, permissionEvaluator.includeInternalComments(actor)); + } + + @Transactional + public TicketResponse updateTicket(UUID ticketId, UpdateTicketRequest request, UUID actorId) { + User actor = userLookupService.requireActor(actorId); + + if (!permissionEvaluator.hasPermission(actor, TicketAction.UPDATE)) { + throw new AccessDeniedException("User is not authorized to perform action: " + TicketAction.UPDATE); + } + + if (request.title() == null && request.description() == null) { + throw new IllegalArgumentException("At least one field must be provided: title or description"); + } + + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); + + boolean changed = false; + + if (request.title() != null) { + String newTitle = request.title().trim(); + if (!StringUtils.hasText(newTitle)) { + throw new IllegalArgumentException("Title must not be blank"); + } + if (!newTitle.equals(ticket.getTitle())) { + String oldTitle = ticket.getTitle(); + ticket.setTitle(newTitle); + auditLogWriter.logChange(ticket, actor, TicketAction.UPDATE, "title", oldTitle, newTitle); + changed = true; + } + } + + if (request.description() != null) { + String newDescription = request.description().trim(); + if (!StringUtils.hasText(newDescription)) { + throw new IllegalArgumentException("Description must not be blank"); + } + if (!newDescription.equals(ticket.getDescription())) { + String oldDescription = ticket.getDescription(); + ticket.setDescription(newDescription); + auditLogWriter.logChange(ticket, actor, TicketAction.UPDATE, "description", oldDescription, newDescription); + changed = true; + } + } + + if (!changed) { + return TicketMapper.toDto(ticket, permissionEvaluator.includeInternalComments(actor)); + } + + Ticket saved = ticketRepository.saveAndFlush(ticket); + return TicketMapper.toDto(saved, permissionEvaluator.includeInternalComments(actor)); + } + @Transactional public TicketResponse addComment(UUID ticketId, TicketCommentRequest request, UUID actorId) { User actor = userLookupService.requireActor(actorId); diff --git a/src/main/java/org/fmazmz/casemanager/ticket/dto/UpdateTicketPriorityRequest.java b/src/main/java/org/fmazmz/casemanager/ticket/dto/UpdateTicketPriorityRequest.java new file mode 100644 index 0000000..a528db5 --- /dev/null +++ b/src/main/java/org/fmazmz/casemanager/ticket/dto/UpdateTicketPriorityRequest.java @@ -0,0 +1,15 @@ +package org.fmazmz.casemanager.ticket.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.fmazmz.casemanager.ticket.domain.Priority; + +public record UpdateTicketPriorityRequest( + @NotNull + Priority priority, + @NotBlank + @Size(max = 5000, message = "Internal comment must be at most 5000 characters") + String internalComment +) { +} diff --git a/src/main/java/org/fmazmz/casemanager/ticket/dto/UpdateTicketRequest.java b/src/main/java/org/fmazmz/casemanager/ticket/dto/UpdateTicketRequest.java new file mode 100644 index 0000000..8ee16b0 --- /dev/null +++ b/src/main/java/org/fmazmz/casemanager/ticket/dto/UpdateTicketRequest.java @@ -0,0 +1,11 @@ +package org.fmazmz.casemanager.ticket.dto; + +import jakarta.validation.constraints.Size; + +public record UpdateTicketRequest( + @Size(min = 5, max = 100, message = "Title must be between 5 and 100 characters") + String title, + @Size(min = 5, max = 5000, message = "Description must be between 5 and 5000 characters") + String description +) { +} diff --git a/src/main/java/org/fmazmz/casemanager/ticket/http/TicketApi.java b/src/main/java/org/fmazmz/casemanager/ticket/http/TicketApi.java index 82cafd7..acf2ed2 100644 --- a/src/main/java/org/fmazmz/casemanager/ticket/http/TicketApi.java +++ b/src/main/java/org/fmazmz/casemanager/ticket/http/TicketApi.java @@ -7,10 +7,12 @@ import jakarta.validation.Valid; import org.fmazmz.casemanager.common.api.openapi.NotFoundApiResponse; import org.fmazmz.casemanager.common.api.openapi.StandardRestApiResponses; +import org.fmazmz.casemanager.ticket.dto.UpdateTicketPriorityRequest; import org.fmazmz.casemanager.ticket.dto.ChangeTicketStatusRequest; import org.fmazmz.casemanager.ticket.dto.CreateTicketRequest; import org.fmazmz.casemanager.ticket.dto.TicketCommentRequest; import org.fmazmz.casemanager.ticket.dto.TicketResponse; +import org.fmazmz.casemanager.ticket.dto.UpdateTicketRequest; import org.fmazmz.casemanager.user.authentication.CurrentUser; import org.fmazmz.casemanager.user.domain.User; import org.fmazmz.casemanager.common.api.ApiResponseWrapper; @@ -127,6 +129,26 @@ ResponseEntity> changeTicketStatus( @Valid @RequestBody ChangeTicketStatusRequest request ); + @Operation(summary = "Update ticket priority") + @ApiResponse(responseCode = "200", description = "Updated", useReturnTypeSchema = true) + @NotFoundApiResponse + @PatchMapping(path = "{ticketId}/priority", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> changeTicketPriority( + @Parameter(hidden = true) @CurrentUser User actor, + @PathVariable UUID ticketId, + @Valid @RequestBody UpdateTicketPriorityRequest request + ); + + @Operation(summary = "Update ticket title and/or description") + @ApiResponse(responseCode = "200", description = "Updated", useReturnTypeSchema = true) + @NotFoundApiResponse + @PatchMapping(path = "{ticketId}", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> updateTicket( + @Parameter(hidden = true) @CurrentUser User actor, + @PathVariable UUID ticketId, + @Valid @RequestBody UpdateTicketRequest request + ); + @Operation(summary = "Add a comment (public or internal work note; visibility is enforced server-side)") @ApiResponse(responseCode = "202", description = "Accepted", useReturnTypeSchema = true) @NotFoundApiResponse diff --git a/src/main/java/org/fmazmz/casemanager/ticket/http/TicketController.java b/src/main/java/org/fmazmz/casemanager/ticket/http/TicketController.java index fec3bc0..d9e94f3 100644 --- a/src/main/java/org/fmazmz/casemanager/ticket/http/TicketController.java +++ b/src/main/java/org/fmazmz/casemanager/ticket/http/TicketController.java @@ -1,10 +1,12 @@ package org.fmazmz.casemanager.ticket.http; import jakarta.validation.Valid; +import org.fmazmz.casemanager.ticket.dto.UpdateTicketPriorityRequest; import org.fmazmz.casemanager.ticket.dto.ChangeTicketStatusRequest; import org.fmazmz.casemanager.ticket.dto.CreateTicketRequest; import org.fmazmz.casemanager.ticket.dto.TicketCommentRequest; import org.fmazmz.casemanager.ticket.dto.TicketResponse; +import org.fmazmz.casemanager.ticket.dto.UpdateTicketRequest; import org.fmazmz.casemanager.ticket.application.TicketOrchestrator; import org.fmazmz.casemanager.ticket.application.TicketQueryFacade; import org.fmazmz.casemanager.user.authentication.CurrentUser; @@ -127,6 +129,30 @@ public ResponseEntity> changeTicketStatus( return ResponseEntity.ok(new ApiResponseWrapper<>(response)); } + @PatchMapping("{ticketId}/priority") + @Override + public ResponseEntity> changeTicketPriority( + @CurrentUser User actor, + @PathVariable UUID ticketId, + @RequestBody @Valid UpdateTicketPriorityRequest request) { + + TicketResponse response = ticketOrchestrator.changePriority(ticketId, request, actor.getId()); + + return ResponseEntity.ok(new ApiResponseWrapper<>(response)); + } + + @PatchMapping("{ticketId}") + @Override + public ResponseEntity> updateTicket( + @CurrentUser User actor, + @PathVariable UUID ticketId, + @RequestBody @Valid UpdateTicketRequest request) { + + TicketResponse response = ticketOrchestrator.updateTicket(ticketId, request, actor.getId()); + + return ResponseEntity.ok(new ApiResponseWrapper<>(response)); + } + @PostMapping("{ticketId}/comment") @Override public ResponseEntity> comment( diff --git a/src/main/java/org/fmazmz/casemanager/user/domain/rbac/RoleName.java b/src/main/java/org/fmazmz/casemanager/user/domain/rbac/RoleName.java index 2b99f44..c5d14d8 100644 --- a/src/main/java/org/fmazmz/casemanager/user/domain/rbac/RoleName.java +++ b/src/main/java/org/fmazmz/casemanager/user/domain/rbac/RoleName.java @@ -23,7 +23,6 @@ public enum RoleName { REPORTER(EnumSet.of( TicketAction.CREATE, TicketAction.READ, - TicketAction.UPDATE, TicketAction.COMMENT_PUBLIC, TicketAction.UPLOAD_ATTACHMENT, TicketAction.REOPEN