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
21 changes: 20 additions & 1 deletion frontend/src/pages/CaseDetail.jsx
Original file line number Diff line number Diff line change
@@ -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('');
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -211,6 +222,14 @@ const CaseDetail = ({ caseData, onBack, onGoToPet, currentUserId, userRole }) =>
<option value="AWAITING_INFO">Väntar på svar</option>
</select>
</div>
{caseData.assignedVetId === currentUserId && (
<button
onClick={handleReleaseCase}
className="bg-slate-700 hover:bg-slate-600 text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg flex items-center gap-2 self-end"
>
<UserMinus size={14} /> Släpp ärende
</button>
)}
<button
onClick={() => setShowCloseModal(true)}
className="bg-red-500 hover:bg-red-600 text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg shadow-red-500/20 flex items-center gap-2 self-end"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/services/api.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const medicalRecordService = {

// Arbetsflöde (Veterinär)
assignVet: (id, vetId) => api.put(`/medical-records/${id}/assign-vet`, { vetId }),
unassignVet: (id) => api.put(`/medical-records/${id}/unassign-vet`),
updateStatus: (id, status) => api.put(`/medical-records/${id}/status`, { status }),
closeRecord: (id) => api.put(`/medical-records/${id}/close`),
getMyAssignedRecords: () => api.get('/medical-records/my-assigned'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,24 @@ public ResponseEntity<MedicalRecordResponse> assignVet(
);
}

// PUT /api/medical-records/{id}/unassign-vet
@PutMapping("/{id}/unassign-vet")
@Transactional
public ResponseEntity<MedicalRecordResponse> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public enum ActivityType {
STATUS_CHANGED,
COMMENT_ADDED,
ASSIGNED,
UNASSIGNED,
UPDATED
}
18 changes: 18 additions & 0 deletions src/main/java/org/example/vet1177/policy/MedicalRecordPolicy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
// /status, /close, /assign-vet förblir VET/ADMIN-only på URL-nivå.
.requestMatchers(HttpMethod.PUT, "/api/medical-records/*/close").hasAnyRole("VET", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/medical-records/*/assign-vet").hasAnyRole("VET", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/medical-records/*/unassign-vet").hasAnyRole("VET", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/medical-records/*/status").hasAnyRole("VET", "ADMIN")

// ─── VET/ADMIN: klinik-vy ───
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down