diff --git a/src/main/java/org/example/projektarendehantering/application/service/CaseMapper.java b/src/main/java/org/example/projektarendehantering/application/service/CaseMapper.java index 9e09a09..9ae5787 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/CaseMapper.java +++ b/src/main/java/org/example/projektarendehantering/application/service/CaseMapper.java @@ -23,6 +23,8 @@ public CaseDTO toDTO(CaseEntity entity) { .description(entity.getDescription()) .createdAt(entity.getCreatedAt()) .patientId(entity.getPatient() != null ? entity.getPatient().getId() : null) + .ownerId(entity.getOwnerId()) + .handlerId(entity.getHandlerId()) .build(); if (entity.getNotes() != null) { @@ -46,6 +48,8 @@ public CaseEntity toEntity(CaseDTO dto) { .title(dto.getTitle()) .description(dto.getDescription()) .createdAt(dto.getCreatedAt()) + .ownerId(dto.getOwnerId()) + .handlerId(dto.getHandlerId()) .build(); } } diff --git a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java index 3df175f..58265ff 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java @@ -1,5 +1,6 @@ package org.example.projektarendehantering.application.service; +import jakarta.validation.Valid; import org.example.projektarendehantering.common.Actor; import org.example.projektarendehantering.common.BadRequestException; import org.example.projektarendehantering.common.CaseStatus; @@ -202,16 +203,19 @@ public CaseDTO assignUsers(Actor actor, UUID caseId, CaseAssignmentDTO dto) { if (entity.getStatus() == CaseStatus.CLOSED) { throw new BadRequestException("Case is closed"); } + if (isDoctor(actor)) { - if (entity.getOwnerId() == null || !entity.getOwnerId().equals(actor.userId())) { + // Doctors can only modify if they are owner OR if it's unowned + if (entity.getOwnerId() != null && !entity.getOwnerId().equals(actor.userId())) { throw new NotAuthorizedException("Not allowed to modify assignments for this case"); } - if (dto.getOwnerId() != null) { - throw new NotAuthorizedException("Not allowed to change owner for this case"); - } } - if (isManager(actor) && dto.getOwnerId() != null) { + if(dto.getOwnerId() == null && dto.getHandlerId() == null) { + throw new BadRequestException("At least one of ownerId or handlerId must be provided."); + } + + if (dto.getOwnerId() != null) { UUID ownerId = requireEmployeeWithRole(dto.getOwnerId(), Set.of(Role.DOCTOR), "ownerId"); entity.setOwnerId(ownerId); } @@ -221,10 +225,14 @@ public CaseDTO assignUsers(Actor actor, UUID caseId, CaseAssignmentDTO dto) { } CaseStatus previousStatus = entity.getStatus(); - entity.setStatus(CaseStatus.HANDLER_ASSIGNED); - CaseEntity savedEntity = caseRepository.save(entity); - recordStatusChange(actor, savedEntity.getId(), previousStatus, CaseStatus.HANDLER_ASSIGNED); - return caseMapper.toDTO(savedEntity); + if (previousStatus != CaseStatus.ASSIGNED) { + entity.setStatus(CaseStatus.ASSIGNED); + CaseEntity savedEntity = caseRepository.save(entity); + recordStatusChange(actor, savedEntity.getId(), previousStatus, CaseStatus.ASSIGNED); + return caseMapper.toDTO(savedEntity); + } + + return caseMapper.toDTO(caseRepository.save(entity)); } private UUID requireEmployeeWithRole(UUID id, Set allowedRoles, String fieldName) { diff --git a/src/main/java/org/example/projektarendehantering/application/service/EmployeeService.java b/src/main/java/org/example/projektarendehantering/application/service/EmployeeService.java index 9227dea..76f28fb 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/EmployeeService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/EmployeeService.java @@ -55,6 +55,13 @@ public List getAllEmployees(Actor actor) { .collect(Collectors.toList()); } + @Transactional(readOnly = true) + public List findByRole(Role role) { + return employeeRepository.findAllByRole(role).stream() + .map(employeeMapper::toDTO) + .collect(Collectors.toList()); + } + private void requireCanManageEmployees(Actor actor) { if (actor == null) { throw new NotAuthorizedException("Missing actor"); diff --git a/src/main/java/org/example/projektarendehantering/common/CaseStatus.java b/src/main/java/org/example/projektarendehantering/common/CaseStatus.java index 988b805..75b0ea8 100644 --- a/src/main/java/org/example/projektarendehantering/common/CaseStatus.java +++ b/src/main/java/org/example/projektarendehantering/common/CaseStatus.java @@ -2,7 +2,7 @@ public enum CaseStatus { CREATED, - HANDLER_ASSIGNED, + ASSIGNED, COMMUNICATION, UPDATED, CLOSED diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java index f80cafb..101adf2 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java @@ -33,7 +33,6 @@ public class CaseEntity { private String description; private Instant createdAt; private UUID handlerId; - private UUID otherId; @ManyToOne(optional = true) @JoinColumn(name = "patient_id", nullable = true) // Optional because the patient can be null diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java index 17219af..d298b22 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java @@ -15,5 +15,4 @@ public interface CaseRepository extends JpaRepository { List findAllByOwnerIdAndStatusNot(UUID ownerId, CaseStatus status); List findAllByHandlerId(UUID handlerId); List findAllByHandlerIdAndStatusNot(UUID handlerId, CaseStatus status); - List findAllByOtherId(UUID otherId); } diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/EmployeeRepository.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/EmployeeRepository.java index 081233b..fa7dfa3 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/EmployeeRepository.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/EmployeeRepository.java @@ -1,11 +1,14 @@ package org.example.projektarendehantering.infrastructure.persistence; +import org.example.projektarendehantering.common.Role; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; import java.util.UUID; public interface EmployeeRepository extends JpaRepository { Optional findByGithubUsername(String githubUsername); + List findAllByRole(Role role); } diff --git a/src/main/java/org/example/projektarendehantering/presentation/dto/CaseAssignmentDTO.java b/src/main/java/org/example/projektarendehantering/presentation/dto/CaseAssignmentDTO.java index 02be6a8..8bec477 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/dto/CaseAssignmentDTO.java +++ b/src/main/java/org/example/projektarendehantering/presentation/dto/CaseAssignmentDTO.java @@ -15,6 +15,5 @@ public class CaseAssignmentDTO { private UUID ownerId; private UUID handlerId; - private UUID otherId; } diff --git a/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java b/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java index e8ba0ba..692e132 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java +++ b/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java @@ -23,6 +23,8 @@ public class CaseDTO { private String description; private Instant createdAt; private UUID patientId; + private UUID ownerId; + private UUID handlerId; @Builder.Default private List notes = new ArrayList<>(); diff --git a/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java b/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java index 497b778..3611853 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java +++ b/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java @@ -1,8 +1,10 @@ package org.example.projektarendehantering.presentation.web; import org.example.projektarendehantering.application.service.CaseService; +import org.example.projektarendehantering.application.service.EmployeeService; import org.example.projektarendehantering.application.service.PatientService; import org.example.projektarendehantering.infrastructure.security.SecurityActorAdapter; +import org.example.projektarendehantering.presentation.dto.CaseAssignmentDTO; import org.example.projektarendehantering.presentation.dto.CaseDTO; import org.example.projektarendehantering.presentation.dto.CreateCaseForm; import org.example.projektarendehantering.presentation.dto.UpdateCaseForm; @@ -27,11 +29,13 @@ public class UiController { private final CaseService caseService; private final PatientService patientService; + private final EmployeeService employeeService; private final SecurityActorAdapter securityActorAdapter; - public UiController(CaseService caseService, PatientService patientService, SecurityActorAdapter securityActorAdapter) { + public UiController(CaseService caseService, PatientService patientService, EmployeeService employeeService, SecurityActorAdapter securityActorAdapter) { this.caseService = caseService; this.patientService = patientService; + this.employeeService = employeeService; this.securityActorAdapter = securityActorAdapter; } @@ -82,7 +86,11 @@ public String createCase(@Valid @ModelAttribute("createCaseForm") CreateCaseForm @GetMapping("/ui/cases/{caseId}") public String caseDetail(@PathVariable UUID caseId, Model model) { - caseService.getCase(securityActorAdapter.currentUser(), caseId).ifPresent(c -> model.addAttribute("case", c)); + CaseDTO caseDTO = caseService.getCase(securityActorAdapter.currentUser(), caseId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Case not found")); + model.addAttribute("case", caseDTO); + model.addAttribute("doctors", employeeService.findByRole(org.example.projektarendehantering.common.Role.DOCTOR)); + model.addAttribute("nurses", employeeService.findByRole(org.example.projektarendehantering.common.Role.NURSE)); return "cases/detail"; } @@ -125,5 +133,26 @@ public String deleteCase(@PathVariable UUID caseId) { caseService.deleteCase(securityActorAdapter.currentUser(), caseId); return "redirect:/ui/cases"; } + + @PostMapping("/ui/cases/{caseId}/assignments") + public String assignUsers(@PathVariable UUID caseId, @RequestParam(value = "ownerId", required = false) String ownerId, @RequestParam(value = "handlerId", required = false) String handlerId) { + CaseAssignmentDTO dto = new CaseAssignmentDTO(); + if (ownerId != null && !ownerId.isBlank()) { + try { + dto.setOwnerId(UUID.fromString(ownerId)); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid ownerId UUID"); + } + } + if (handlerId != null && !handlerId.isBlank()) { + try { + dto.setHandlerId(UUID.fromString(handlerId)); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid handlerId UUID"); + } + } + caseService.assignUsers(securityActorAdapter.currentUser(), caseId, dto); + return "redirect:/ui/cases/" + caseId; + } } diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 715fff5..23c9aaa 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -26,9 +26,9 @@ ON CONFLICT (id) DO NOTHING; -- Seed Cases INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) +VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'ASSIGNED', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING; INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) +VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'ASSIGNED', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING; diff --git a/src/main/resources/templates/cases/detail.html b/src/main/resources/templates/cases/detail.html index e0bc6ab..10800e0 100644 --- a/src/main/resources/templates/cases/detail.html +++ b/src/main/resources/templates/cases/detail.html @@ -31,6 +31,31 @@

Case

Created At
Date
+ +
+ +

Assignment

+
+
+ + +
+ +
+
+
diff --git a/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java b/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java index f428052..199a066 100644 --- a/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java +++ b/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java @@ -6,6 +6,7 @@ import org.example.projektarendehantering.common.NotAuthorizedException; import org.example.projektarendehantering.common.Role; import org.example.projektarendehantering.infrastructure.persistence.*; +import org.example.projektarendehantering.presentation.dto.CaseAssignmentDTO; import org.example.projektarendehantering.presentation.dto.CaseDTO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +16,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -315,7 +317,7 @@ void createCase_shouldRecordStatusChangeAuditWithCaseId() { @Test void updateCase_shouldRecordPreviousStatusInAudit() { - caseEntity.setStatus(CaseStatus.HANDLER_ASSIGNED); + caseEntity.setStatus(CaseStatus.ASSIGNED); CaseDTO updateDto = new CaseDTO(); updateDto.setTitle("New title"); updateDto.setDescription("New description"); @@ -327,7 +329,7 @@ void updateCase_shouldRecordPreviousStatusInAudit() { caseService.updateCase(doctorActor, caseId, updateDto); verify(auditService).record(argThat(e -> - "HANDLER_ASSIGNED -> UPDATED".equals(e.getStatusChange()) && + "ASSIGNED -> UPDATED".equals(e.getStatusChange()) && caseId.equals(e.getCaseId()) )); } @@ -433,9 +435,83 @@ void getClosedCases_shouldDenyDoctor() { } @Test - void getClosedCases_shouldDenyNurse() { - assertThatThrownBy(() -> caseService.getClosedCases(nurseActor)) + void assignUsers_shouldAllowDoctorToAssignThemselvesToUnownedCase() { + caseEntity.setOwnerId(null); // Unowned + CaseAssignmentDTO dto = new CaseAssignmentDTO(); + dto.setOwnerId(doctorActor.userId()); + + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + when(employeeRepository.findById(doctorActor.userId())).thenReturn(Optional.of(new EmployeeEntity(doctorActor.userId(), "Doctor", "doctor_user", Role.DOCTOR, Instant.now()))); + when(caseRepository.save(caseEntity)).thenReturn(caseEntity); + when(caseMapper.toDTO(caseEntity)).thenReturn(new CaseDTO()); + + caseService.assignUsers(doctorActor, caseId, dto); + + assertThat(caseEntity.getOwnerId()).isEqualTo(doctorActor.userId()); + verify(caseRepository).save(caseEntity); + } + + @Test + void assignUsers_shouldAllowDoctorToTransferOwnershipOfOwnedCase() { + // doctorActor already owns it (set in setUp) + CaseAssignmentDTO dto = new CaseAssignmentDTO(); + dto.setOwnerId(otherDoctorActor.userId()); + + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + when(employeeRepository.findById(otherDoctorActor.userId())).thenReturn(Optional.of(new EmployeeEntity(otherDoctorActor.userId(), "Other Doctor", "other_doctor_user", Role.DOCTOR, Instant.now()))); + when(caseRepository.save(caseEntity)).thenReturn(caseEntity); + when(caseMapper.toDTO(caseEntity)).thenReturn(new CaseDTO()); + + caseService.assignUsers(doctorActor, caseId, dto); + + assertThat(caseEntity.getOwnerId()).isEqualTo(otherDoctorActor.userId()); + verify(caseRepository).save(caseEntity); + } + + @Test + void assignUsers_shouldDenyDoctorToModifyAssignmentsOfCaseOwnedByOther() { + caseEntity.setOwnerId(otherDoctorActor.userId()); + CaseAssignmentDTO dto = new CaseAssignmentDTO(); + dto.setOwnerId(doctorActor.userId()); + + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + + assertThatThrownBy(() -> caseService.assignUsers(doctorActor, caseId, dto)) .isInstanceOf(NotAuthorizedException.class) - .hasMessageContaining("Not allowed to view closed cases"); + .hasMessageContaining("Not allowed to modify assignments for this case"); + } + + @Test + void assignUsers_shouldAllowManagerToAssignAnyDoctor() { + CaseAssignmentDTO dto = new CaseAssignmentDTO(); + dto.setOwnerId(otherDoctorActor.userId()); + + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + when(employeeRepository.findById(otherDoctorActor.userId())).thenReturn(Optional.of(new EmployeeEntity(otherDoctorActor.userId(), "Other Doctor", "other_doctor_user", Role.DOCTOR, Instant.now()))); + when(caseRepository.save(caseEntity)).thenReturn(caseEntity); + when(caseMapper.toDTO(caseEntity)).thenReturn(new CaseDTO()); + + caseService.assignUsers(managerActor, caseId, dto); + + assertThat(caseEntity.getOwnerId()).isEqualTo(otherDoctorActor.userId()); + verify(caseRepository).save(caseEntity); } + + @Test + void assignUsers_shouldSetStatusToHandlerAssigned() { + caseEntity.setStatus(CaseStatus.CREATED); + CaseAssignmentDTO dto = new CaseAssignmentDTO(); + dto.setHandlerId(nurseActor.userId()); + + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + when(employeeRepository.findById(nurseActor.userId())).thenReturn(Optional.of(new EmployeeEntity(nurseActor.userId(), "Nurse", "nurse_user", Role.NURSE, Instant.now()))); + when(caseRepository.save(caseEntity)).thenReturn(caseEntity); + when(caseMapper.toDTO(caseEntity)).thenReturn(new CaseDTO()); + + caseService.assignUsers(managerActor, caseId, dto); + + assertThat(caseEntity.getStatus()).isEqualTo(CaseStatus.ASSIGNED); + verify(auditService).record(argThat(e -> "CREATED -> ASSIGNED".equals(e.getStatusChange()))); + } + } diff --git a/src/test/resources/data-test.sql b/src/test/resources/data-test.sql index bc9ea56..9a1f9d2 100644 --- a/src/test/resources/data-test.sql +++ b/src/test/resources/data-test.sql @@ -20,7 +20,7 @@ VALUES ('770e8400-e29b-41d4-a716-446655440002', 'Nurse Bob Jones', 'NURSE', CURR -- Seed Cases INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP); +VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'ASSIGNED', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP); INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP); +VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'ASSIGNED', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP);