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 @@ -16,9 +16,13 @@

import backend.fullstack.config.ApiResponse;
import backend.fullstack.config.JwtPrincipal;
import backend.fullstack.deviations.api.dto.DeviationCommentRequest;
import backend.fullstack.deviations.api.dto.DeviationCommentResponse;
import backend.fullstack.deviations.api.dto.DeviationDetailsResponse;
import backend.fullstack.deviations.api.dto.DeviationRequest;
import backend.fullstack.deviations.api.dto.DeviationResponse;
import backend.fullstack.deviations.api.dto.ResolveDeviationRequest;
import backend.fullstack.deviations.api.dto.UpdateDeviationStatusRequest;
import backend.fullstack.deviations.application.DeviationService;
import backend.fullstack.deviations.domain.DeviationStatus;
import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -54,6 +58,23 @@ public ResponseEntity<ApiResponse<List<DeviationResponse>>> getDeviations(
return ResponseEntity.ok(ApiResponse.success("Deviations retrieved", deviations));
}

@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Get deviation by id", description = "Returns one deviation including comment log")
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Deviation retrieved"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Deviation not found")
})
public ResponseEntity<ApiResponse<DeviationDetailsResponse>> getDeviationById(
@AuthenticationPrincipal JwtPrincipal principal,
@PathVariable Long id
) {
DeviationDetailsResponse deviation = deviationService.getDeviationById(principal.organizationId(), id);
return ResponseEntity.ok(ApiResponse.success("Deviation retrieved", deviation));
}

@PostMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Create deviation")
Expand Down Expand Up @@ -87,4 +108,52 @@ public ResponseEntity<ApiResponse<DeviationResponse>> resolveDeviation(
DeviationResponse deviation = deviationService.resolveDeviation(principal.organizationId(), principal.userId(), id, request);
return ResponseEntity.ok(ApiResponse.success("Deviation resolved", deviation));
}

@PatchMapping("/{id}/status")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')")
@Operation(summary = "Update deviation status")
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Deviation status updated"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Validation error"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Deviation not found")
})
public ResponseEntity<ApiResponse<DeviationResponse>> updateDeviationStatus(
@AuthenticationPrincipal JwtPrincipal principal,
@PathVariable Long id,
@Valid @RequestBody UpdateDeviationStatusRequest request
) {
DeviationResponse deviation = deviationService.updateDeviationStatus(
principal.organizationId(),
principal.userId(),
id,
request
);
return ResponseEntity.ok(ApiResponse.success("Deviation status updated", deviation));
}

@PostMapping("/{id}/comments")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Add comment to deviation")
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Comment created"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Validation error"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Deviation not found")
})
public ResponseEntity<ApiResponse<DeviationCommentResponse>> addDeviationComment(
@AuthenticationPrincipal JwtPrincipal principal,
@PathVariable Long id,
@Valid @RequestBody DeviationCommentRequest request
) {
DeviationCommentResponse response = deviationService.addComment(
principal.organizationId(),
principal.userId(),
id,
request
);
return ResponseEntity.status(201).body(ApiResponse.success("Deviation comment created", response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package backend.fullstack.deviations.api.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record DeviationCommentRequest(
@NotBlank(message = "Comment is required")
@Size(max = 2000, message = "Comment must be at most 2000 characters")
String comment
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package backend.fullstack.deviations.api.dto;

import java.time.LocalDateTime;

public record DeviationCommentResponse(
Long id,
String comment,
Long createdById,
String createdBy,
LocalDateTime createdAt
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package backend.fullstack.deviations.api.dto;

import java.time.LocalDateTime;
import java.util.List;

import backend.fullstack.deviations.domain.DeviationModuleType;
import backend.fullstack.deviations.domain.DeviationSeverity;
import backend.fullstack.deviations.domain.DeviationStatus;

public record DeviationDetailsResponse(
Long id,
String title,
String description,
DeviationStatus status,
DeviationSeverity severity,
DeviationModuleType moduleType,
String reportedBy,
LocalDateTime reportedAt,
String resolvedBy,
LocalDateTime resolvedAt,
String resolution,
Long relatedReadingId,
List<DeviationCommentResponse> comments
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package backend.fullstack.deviations.api.dto;

import backend.fullstack.deviations.domain.DeviationStatus;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record UpdateDeviationStatusRequest(
@NotNull(message = "Status is required")
DeviationStatus status,

@Size(max = 2000, message = "Resolution must be at most 2000 characters")
String resolution
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import backend.fullstack.deviations.api.dto.DeviationCommentRequest;
import backend.fullstack.deviations.api.dto.DeviationCommentResponse;
import backend.fullstack.deviations.api.dto.DeviationDetailsResponse;
import backend.fullstack.deviations.api.dto.DeviationMapper;
import backend.fullstack.deviations.api.dto.DeviationRequest;
import backend.fullstack.deviations.api.dto.DeviationResponse;
import backend.fullstack.deviations.api.dto.ResolveDeviationRequest;
import backend.fullstack.deviations.api.dto.UpdateDeviationStatusRequest;
import backend.fullstack.deviations.domain.Deviation;
import backend.fullstack.deviations.domain.DeviationStatus;
import backend.fullstack.deviations.infrastructure.DeviationCommentRepository;
import backend.fullstack.deviations.infrastructure.DeviationRepository;
import backend.fullstack.exceptions.ResourceNotFoundException;
import backend.fullstack.organization.Organization;
Expand All @@ -24,17 +29,20 @@
public class DeviationService {

private final DeviationRepository deviationRepository;
private final DeviationCommentRepository deviationCommentRepository;
private final OrganizationRepository organizationRepository;
private final UserRepository userRepository;
private final DeviationMapper deviationMapper;

public DeviationService(
DeviationRepository deviationRepository,
DeviationCommentRepository deviationCommentRepository,
OrganizationRepository organizationRepository,
UserRepository userRepository,
DeviationMapper deviationMapper
) {
this.deviationRepository = deviationRepository;
this.deviationCommentRepository = deviationCommentRepository;
this.organizationRepository = organizationRepository;
this.userRepository = userRepository;
this.deviationMapper = deviationMapper;
Expand All @@ -53,6 +61,41 @@ public List<DeviationResponse> getDeviations(Long organizationId, DeviationStatu
.toList();
}

@Transactional(readOnly = true)
public DeviationDetailsResponse getDeviationById(Long organizationId, Long deviationId) {
Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId)
.orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId));

List<DeviationCommentResponse> comments = deviationCommentRepository
.findByOrganization_IdAndDeviation_IdOrderByCreatedAtAsc(organizationId, deviationId)
.stream()
.map(comment -> new DeviationCommentResponse(
comment.getId(),
comment.getCommentText(),
comment.getCreatedById(),
comment.getCreatedByName(),
comment.getCreatedAt()
))
.toList();

DeviationResponse base = deviationMapper.toResponse(deviation);
return new DeviationDetailsResponse(
base.id(),
base.title(),
base.description(),
base.status(),
base.severity(),
base.moduleType(),
base.reportedBy(),
base.reportedAt(),
base.resolvedBy(),
base.resolvedAt(),
base.resolution(),
deviation.getRelatedReadingId(),
comments
);
}

public DeviationResponse createDeviation(Long organizationId, Long userId, DeviationRequest request) {
Organization organization = organizationRepository.findById(organizationId)
.orElseThrow(() -> new ResourceNotFoundException("Organization", organizationId));
Expand All @@ -74,27 +117,106 @@ public DeviationResponse createDeviation(Long organizationId, Long userId, Devia
return deviationMapper.toResponse(saved);
}

public DeviationResponse resolveDeviation(Long organizationId, Long userId, Long deviationId, ResolveDeviationRequest request) {
validateResolutionForResolvedStatus(request.resolution());
public DeviationResponse updateDeviationStatus(
Long organizationId,
Long userId,
Long deviationId,
UpdateDeviationStatusRequest request
) {
if (request.status() == DeviationStatus.RESOLVED) {
validateResolutionForResolvedStatus(request.resolution());
}

Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId)
.orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId));

User resolver = userRepository.findById(userId)
User actor = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User", userId));

deviation.setStatus(DeviationStatus.RESOLVED);
deviation.setResolvedBy(resolver);
deviation.setResolvedAt(LocalDateTime.now());
deviation.setResolution(request.resolution());
assertUserInOrganization(actor, organizationId);
assertAllowedTransition(deviation.getStatus(), request.status());

deviation.setStatus(request.status());

if (request.status() == DeviationStatus.RESOLVED) {
deviation.setResolvedBy(actor);
deviation.setResolvedAt(LocalDateTime.now());
deviation.setResolution(request.resolution());
} else {
deviation.setResolvedBy(null);
deviation.setResolvedAt(null);
deviation.setResolution(null);
}

Deviation saved = deviationRepository.save(deviation);
return deviationMapper.toResponse(saved);
}

public DeviationCommentResponse addComment(
Long organizationId,
Long userId,
Long deviationId,
DeviationCommentRequest request
) {
Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId)
.orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId));

User author = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User", userId));

assertUserInOrganization(author, organizationId);

var comment = backend.fullstack.deviations.domain.DeviationComment.builder()
.organization(deviation.getOrganization())
.deviation(deviation)
.createdBy(author)
.commentText(request.comment())
.build();

var saved = deviationCommentRepository.save(comment);
return new DeviationCommentResponse(
saved.getId(),
saved.getCommentText(),
saved.getCreatedById(),
saved.getCreatedByName(),
saved.getCreatedAt()
);
}

public DeviationResponse resolveDeviation(Long organizationId, Long userId, Long deviationId, ResolveDeviationRequest request) {
return updateDeviationStatus(
organizationId,
userId,
deviationId,
new UpdateDeviationStatusRequest(DeviationStatus.RESOLVED, request.resolution())
);
}

private void validateResolutionForResolvedStatus(String resolution) {
if (resolution == null || resolution.isBlank()) {
throw new IllegalArgumentException("Resolution text is required when status is RESOLVED");
}
}

private void assertUserInOrganization(User user, Long organizationId) {
if (!organizationId.equals(user.getOrganizationId())) {
throw new IllegalArgumentException("User does not belong to this organization");
}
}

private void assertAllowedTransition(DeviationStatus current, DeviationStatus target) {
if (current == target) {
return;
}

boolean valid = switch (current) {
case OPEN -> target == DeviationStatus.IN_PROGRESS || target == DeviationStatus.RESOLVED;
case IN_PROGRESS -> target == DeviationStatus.RESOLVED;
case RESOLVED -> false;
};

if (!valid) {
throw new IllegalArgumentException("Invalid status transition: " + current + " -> " + target);
}
}
}
Loading
Loading