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 @@ -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) {
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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));
Comment thread
mattknatt marked this conversation as resolved.
}

private UUID requireEmployeeWithRole(UUID id, Set<Role> allowedRoles, String fieldName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ public List<EmployeeDTO> getAllEmployees(Actor actor) {
.collect(Collectors.toList());
}

@Transactional(readOnly = true)
public List<EmployeeDTO> findByRole(Role role) {
return employeeRepository.findAllByRole(role).stream()
.map(employeeMapper::toDTO)
.collect(Collectors.toList());
}
Comment thread
mattknatt marked this conversation as resolved.

private void requireCanManageEmployees(Actor actor) {
if (actor == null) {
throw new NotAuthorizedException("Missing actor");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

public enum CaseStatus {
CREATED,
HANDLER_ASSIGNED,
ASSIGNED,
COMMUNICATION,
UPDATED,
CLOSED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,4 @@ public interface CaseRepository extends JpaRepository<CaseEntity, UUID> {
List<CaseEntity> findAllByOwnerIdAndStatusNot(UUID ownerId, CaseStatus status);
List<CaseEntity> findAllByHandlerId(UUID handlerId);
List<CaseEntity> findAllByHandlerIdAndStatusNot(UUID handlerId, CaseStatus status);
List<CaseEntity> findAllByOtherId(UUID otherId);
}
Original file line number Diff line number Diff line change
@@ -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<EmployeeEntity, UUID> {
Optional<EmployeeEntity> findByGithubUsername(String githubUsername);
List<EmployeeEntity> findAllByRole(Role role);
}

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,5 @@ public class CaseAssignmentDTO {

private UUID ownerId;
private UUID handlerId;
private UUID otherId;

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CaseNoteDTO> notes = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -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;
}
}

4 changes: 2 additions & 2 deletions src/main/resources/data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
25 changes: 25 additions & 0 deletions src/main/resources/templates/cases/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ <h1>Case</h1>
<div class="k">Created At</div>
<div class="v" th:text="${case.createdAt}">Date</div>
</div>

<hr style="border: 0; border-top: 1px solid var(--border); margin: 20px 0;"/>

<h3>Assignment</h3>
<form th:action="@{/ui/cases/{id}/assignments(id=${case.id})}" method="post" class="form">
<div style="display: flex; gap: 20px; align-items: flex-end;">
<label class="field" style="flex: 1;">
<span class="label">Responsible Doctor</span>
<select name="ownerId" class="input">
<option value="">- Unassigned -</option>
<option th:each="doc : ${doctors}" th:value="${doc.id}" th:text="${doc.displayName}" th:selected="${doc.id == case.ownerId}"></option>
</select>
</label>
<label class="field" style="flex: 1;">
<span class="label">Assigned Nurse</span>
<select name="handlerId" class="input">
<option value="">- Unassigned -</option>
<option th:each="nurse : ${nurses}" th:value="${nurse.id}" th:text="${nurse.displayName}" th:selected="${nurse.id == case.handlerId}"></option>
</select>
</label>
<div class="actions">
<button class="button" type="submit">Update Assignment</button>
</div>
</div>
</form>
</section>

<section class="panel" th:if="${case != null}">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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())
));
}
Expand Down Expand Up @@ -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())));
}

}
4 changes: 2 additions & 2 deletions src/test/resources/data-test.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);