From c64ac09c62cb43fa7cbef4c85bab10a375ff5791 Mon Sep 17 00:00:00 2001 From: Annika Holmqvist Date: Thu, 23 Apr 2026 17:14:08 +0200 Subject: [PATCH 1/6] feat: add UNASSIGNED to ActivityType enum --- src/main/java/org/example/vet1177/entities/ActivityType.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/example/vet1177/entities/ActivityType.java b/src/main/java/org/example/vet1177/entities/ActivityType.java index a9c6c063..3091540e 100644 --- a/src/main/java/org/example/vet1177/entities/ActivityType.java +++ b/src/main/java/org/example/vet1177/entities/ActivityType.java @@ -5,5 +5,6 @@ public enum ActivityType { STATUS_CHANGED, COMMENT_ADDED, ASSIGNED, + UNASSIGNED, UPDATED } From c4ad0dce90a69d84ed408a194bfaf973239ee643 Mon Sep 17 00:00:00 2001 From: Annika Holmqvist Date: Thu, 23 Apr 2026 17:14:22 +0200 Subject: [PATCH 2/6] feat: add canUnassignVet policy and unassignVet service method for handling VET unassignments --- .../vet1177/policy/MedicalRecordPolicy.java | 18 +++++++++++ .../services/MedicalRecordService.java | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/main/java/org/example/vet1177/policy/MedicalRecordPolicy.java b/src/main/java/org/example/vet1177/policy/MedicalRecordPolicy.java index 8d632c06..3ebc16ab 100644 --- a/src/main/java/org/example/vet1177/policy/MedicalRecordPolicy.java +++ b/src/main/java/org/example/vet1177/policy/MedicalRecordPolicy.java @@ -99,6 +99,24 @@ public void canAssignVet(User user, MedicalRecord record, User vetToAssign) { } } + // Endast den tilldelade VET:en själv (eller ADMIN) får släppa ärendet. + // Kollegor på samma klinik spärras — principen är "man äger sitt eget arbete". + public void canUnassignVet(User user, MedicalRecord record) { + switch (user.getRole()) { + case OWNER -> + throw new ForbiddenException("Ägare får inte släppa ärenden"); + case VET -> { + if (record.getAssignedVet() == null || + !record.getAssignedVet().getId().equals(user.getId())) + throw new ForbiddenException("Du kan bara släppa ärenden du själv är tilldelad"); + } + case ADMIN -> {} + } + + if (record.getStatus().isFinal()) + throw new BusinessRuleException("Stängda ärenden kan inte ändras"); + } + public void canClose(User user, MedicalRecord record) { switch (user.getRole()) { case OWNER -> diff --git a/src/main/java/org/example/vet1177/services/MedicalRecordService.java b/src/main/java/org/example/vet1177/services/MedicalRecordService.java index ab85dfa2..e0f59eab 100644 --- a/src/main/java/org/example/vet1177/services/MedicalRecordService.java +++ b/src/main/java/org/example/vet1177/services/MedicalRecordService.java @@ -202,6 +202,37 @@ public MedicalRecord assignVet(UUID recordId, User vetToAssign, User updatedBy) } + // Släpper nuvarande handläggare från ärendet. Återställer status till OPEN om + // ärendet var IN_PROGRESS (speglar assignVet som satte IN_PROGRESS); andra statusar + // som VET manuellt satt (t.ex. AWAITING_INFO) bevaras. + public MedicalRecord unassignVet(UUID recordId, User updatedBy) { + log.info("Unassigning vet from record id={}", recordId); + MedicalRecord record = getById(recordId); + + if (record.getAssignedVet() == null) { + throw new BusinessRuleException("Ärendet har ingen tilldelad handläggare"); + } + + User releasedVet = record.getAssignedVet(); + + record.setAssignedVet(null); + if (record.getStatus() == RecordStatus.IN_PROGRESS) { + record.setStatus(RecordStatus.OPEN); + } + record.setUpdatedBy(updatedBy); + + MedicalRecord updated = medicalRecordRepository.save(record); + + activityLogService.log( + ActivityType.UNASSIGNED, + "Veterinär " + releasedVet.getName() + " har släppt ärendet", + updatedBy, + updated + ); + + return updated; + } + public MedicalRecord updateStatus(UUID recordId, RecordStatus newStatus, User updatedBy) { log.info("Updating status of record id={} to {}", recordId, newStatus); MedicalRecord record = getById(recordId); From 13e792e246739a896da588d5c4735a5e75f07f6f Mon Sep 17 00:00:00 2001 From: Annika Holmqvist Date: Thu, 23 Apr 2026 17:14:28 +0200 Subject: [PATCH 3/6] test: add canUnassignVet tests to validate role-based unassignment rules --- .../policy/MedicalRecordPolicyTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/test/java/org/example/vet1177/policy/MedicalRecordPolicyTest.java b/src/test/java/org/example/vet1177/policy/MedicalRecordPolicyTest.java index 68ec32bd..54c24887 100644 --- a/src/test/java/org/example/vet1177/policy/MedicalRecordPolicyTest.java +++ b/src/test/java/org/example/vet1177/policy/MedicalRecordPolicyTest.java @@ -223,6 +223,61 @@ void canAssignVet_owner_shouldThrowForbidden() { .isInstanceOf(ForbiddenException.class); } + // ------------------------------------------------------------------------- + // canUnassignVet — endast den tilldelade veterinären själv (+ ADMIN) + // ------------------------------------------------------------------------- + + @Test + void canUnassignVet_owner_shouldThrowForbidden() { + openRecord.setAssignedVet(vet); + assertThatThrownBy(() -> policy.canUnassignVet(owner, openRecord)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Ägare får inte släppa ärenden"); + } + + @Test + void canUnassignVet_vetSelf_shouldNotThrow() { + openRecord.setAssignedVet(vet); + assertThatNoException().isThrownBy(() -> policy.canUnassignVet(vet, openRecord)); + } + + @Test + void canUnassignVet_vetColleagueSameClinic_shouldThrowForbidden() throws Exception { + openRecord.setAssignedVet(vet); + User colleagueVet = new User("Dr. Kollega", "kollega@vet.se", "hash", Role.VET, clinic); + setPrivateField(colleagueVet, "id", UUID.randomUUID()); + + assertThatThrownBy(() -> policy.canUnassignVet(colleagueVet, openRecord)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Du kan bara släppa ärenden du själv är tilldelad"); + } + + @Test + void canUnassignVet_vetOnRecordWithoutAssignee_shouldThrowForbidden() { + openRecord.setAssignedVet(null); + assertThatThrownBy(() -> policy.canUnassignVet(vet, openRecord)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void canUnassignVet_admin_shouldNotThrow() { + openRecord.setAssignedVet(vet); + assertThatNoException().isThrownBy(() -> policy.canUnassignVet(admin, openRecord)); + } + + @Test + void canUnassignVet_adminOnRecordWithoutAssignee_shouldNotThrow() { + openRecord.setAssignedVet(null); + assertThatNoException().isThrownBy(() -> policy.canUnassignVet(admin, openRecord)); + } + + @Test + void canUnassignVet_closedRecord_shouldThrowBusinessRule() { + closedRecord.setAssignedVet(vet); + assertThatThrownBy(() -> policy.canUnassignVet(vet, closedRecord)) + .isInstanceOf(BusinessRuleException.class); + } + // ------------------------------------------------------------------------- // canViewClinic — OWNER blockeras, VET på rätt klinik släpps, ADMIN alltid // ------------------------------------------------------------------------- From 6734de10cb3903fb16b870c1d6a32794bba4956a Mon Sep 17 00:00:00 2001 From: Annika Holmqvist Date: Thu, 23 Apr 2026 17:14:39 +0200 Subject: [PATCH 4/6] feat: add unassignVet endpoint and tests for VET unassignment handling --- .../controller/MedicalRecordController.java | 18 +++++ .../services/MedicalRecordServiceTest.java | 71 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/main/java/org/example/vet1177/controller/MedicalRecordController.java b/src/main/java/org/example/vet1177/controller/MedicalRecordController.java index 7ef43704..12b98e7f 100644 --- a/src/main/java/org/example/vet1177/controller/MedicalRecordController.java +++ b/src/main/java/org/example/vet1177/controller/MedicalRecordController.java @@ -228,6 +228,24 @@ public ResponseEntity assignVet( ); } + // PUT /api/medical-records/{id}/unassign-vet + @PutMapping("/{id}/unassign-vet") + @Transactional + public ResponseEntity unassignVet( + @PathVariable UUID id, + @AuthenticationPrincipal User currentUser) { + + log.info("PUT /api/medical-records/{}/unassign-vet", id); + MedicalRecord record = medicalRecordService.getById(id); + medicalRecordPolicy.canUnassignVet(currentUser, record); + + return ResponseEntity.ok( + MedicalRecordResponse.from( + medicalRecordService.unassignVet(id, currentUser) + ) + ); + } + // PUT /api/medical-records/{id}/status @PutMapping("/{id}/status") @Transactional diff --git a/src/test/java/org/example/vet1177/services/MedicalRecordServiceTest.java b/src/test/java/org/example/vet1177/services/MedicalRecordServiceTest.java index 66469002..0f5ec886 100644 --- a/src/test/java/org/example/vet1177/services/MedicalRecordServiceTest.java +++ b/src/test/java/org/example/vet1177/services/MedicalRecordServiceTest.java @@ -327,6 +327,77 @@ void assignVet_whenAssigneeNotVet_shouldThrowBusinessRuleException() { verify(medicalRecordRepository, never()).save(any()); } + // ------------------------------------------------------------------------- + // unassignVet + // ------------------------------------------------------------------------- + + @Test + void unassignVet_shouldClearAssignedVetAndReturn() { + User vet = new User("Dr. Erik", "erik@vet.se", "hash", Role.VET); + record.setAssignedVet(vet); + record.setStatus(RecordStatus.IN_PROGRESS); + when(medicalRecordRepository.findById(recordId)).thenReturn(Optional.of(record)); + when(medicalRecordRepository.save(record)).thenReturn(record); + + MedicalRecord result = medicalRecordService.unassignVet(recordId, currentUser); + + assertThat(result.getAssignedVet()).isNull(); + } + + @Test + void unassignVet_whenStatusInProgress_shouldResetToOpen() { + User vet = new User("Dr. Erik", "erik@vet.se", "hash", Role.VET); + record.setAssignedVet(vet); + record.setStatus(RecordStatus.IN_PROGRESS); + when(medicalRecordRepository.findById(recordId)).thenReturn(Optional.of(record)); + when(medicalRecordRepository.save(record)).thenReturn(record); + + MedicalRecord result = medicalRecordService.unassignVet(recordId, currentUser); + + assertThat(result.getStatus()).isEqualTo(RecordStatus.OPEN); + } + + @Test + void unassignVet_whenStatusAwaitingInfo_shouldKeepStatus() { + User vet = new User("Dr. Erik", "erik@vet.se", "hash", Role.VET); + record.setAssignedVet(vet); + record.setStatus(RecordStatus.AWAITING_INFO); + when(medicalRecordRepository.findById(recordId)).thenReturn(Optional.of(record)); + when(medicalRecordRepository.save(record)).thenReturn(record); + + MedicalRecord result = medicalRecordService.unassignVet(recordId, currentUser); + + assertThat(result.getStatus()).isEqualTo(RecordStatus.AWAITING_INFO); + } + + @Test + void unassignVet_shouldLogActivityWithVetName() { + User vet = new User("Dr. Erik Vet", "erik@vet.se", "hash", Role.VET); + record.setAssignedVet(vet); + when(medicalRecordRepository.findById(recordId)).thenReturn(Optional.of(record)); + when(medicalRecordRepository.save(record)).thenReturn(record); + + medicalRecordService.unassignVet(recordId, currentUser); + + verify(activityLogService).log( + eq(ActivityType.UNASSIGNED), + eq("Veterinär Dr. Erik Vet har släppt ärendet"), + eq(currentUser), + eq(record)); + } + + @Test + void unassignVet_whenNoAssignedVet_shouldThrowBusinessRuleException() { + record.setAssignedVet(null); + when(medicalRecordRepository.findById(recordId)).thenReturn(Optional.of(record)); + + assertThatThrownBy(() -> medicalRecordService.unassignVet(recordId, currentUser)) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Ärendet har ingen tilldelad handläggare"); + + verify(medicalRecordRepository, never()).save(any()); + } + // ------------------------------------------------------------------------- // updateStatus // ------------------------------------------------------------------------- From 2e0a19aef7e4bcdbe8341f32b9aeeb3fec6c1933 Mon Sep 17 00:00:00 2001 From: Annika Holmqvist Date: Thu, 23 Apr 2026 17:14:49 +0200 Subject: [PATCH 5/6] feat: add unassignVet functionality and UI for releasing cases by assigned vets --- frontend/src/pages/CaseDetail.jsx | 21 ++++++++++++++++++++- frontend/src/services/api.jsx | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/CaseDetail.jsx b/frontend/src/pages/CaseDetail.jsx index 339f8c34..77e7b6fd 100644 --- a/frontend/src/pages/CaseDetail.jsx +++ b/frontend/src/pages/CaseDetail.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { commentService, activityService, attachmentService, medicalRecordService } from '../services/api'; import { STATUS_MAP } from '../utils/statusHelper'; -import { Stethoscope, Lock, FileText, CheckCircle, Upload, Trash2, ExternalLink } from 'lucide-react'; +import { Stethoscope, Lock, FileText, CheckCircle, Upload, Trash2, ExternalLink, UserMinus } from 'lucide-react'; const CaseDetail = ({ caseData, onBack, onGoToPet, currentUserId, userRole }) => { const [newMessage, setNewMessage] = useState(''); @@ -87,6 +87,17 @@ const CaseDetail = ({ caseData, onBack, onGoToPet, currentUserId, userRole }) => } }; + const handleReleaseCase = async () => { + if (!window.confirm("Släppa ärendet? Det blir ledigt för en annan veterinär att ta över.")) return; + try { + const res = await medicalRecordService.unassignVet(caseData.id); + if (res?.data?.status) setLocalStatus(res.data.status); + await refetchActivityLog(); + } catch (error) { + alert("Kunde inte släppa ärendet."); + } + }; + // FIX: Atomisk stängning enligt förra fyndet const handleCloseCase = async () => { if (!clinicalNote.trim()) return alert("Vänligen skriv en slutnotering."); @@ -211,6 +222,14 @@ const CaseDetail = ({ caseData, onBack, onGoToPet, currentUserId, userRole }) => + {caseData.assignedVetId === currentUserId && ( + + )}