From 21ee3d39d12c952f2f520f7d76fe887ea6d48788 Mon Sep 17 00:00:00 2001 From: Johan Briger Date: Sun, 5 Apr 2026 18:42:19 +0200 Subject: [PATCH 1/5] feat: implement AttachmentController and record-based file management --- .../controller/AttachmentController.java | 85 +++++++++++++++++++ .../repository/AttachmentRepository.java | 2 +- .../vet1177/services/AttachmentService.java | 14 ++- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/vet1177/controller/AttachmentController.java diff --git a/src/main/java/org/example/vet1177/controller/AttachmentController.java b/src/main/java/org/example/vet1177/controller/AttachmentController.java new file mode 100644 index 0000000..4d415ee --- /dev/null +++ b/src/main/java/org/example/vet1177/controller/AttachmentController.java @@ -0,0 +1,85 @@ +package org.example.vet1177.controller; + + +import org.example.vet1177.dto.request.attachment.AttachmentRequest; +import org.example.vet1177.dto.response.attachment.AttachmentResponse; +import org.example.vet1177.entities.User; +import org.example.vet1177.services.AttachmentService; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/attachments") +public class AttachmentController { + + private final AttachmentService attachmentService; + + public AttachmentController(AttachmentService attachmentService) { + this.attachmentService = attachmentService; + } + + /** + * Krav: POST /api/attachments/record/{recordId} + * Laddar upp en fil kopplad till en specifik journal. + */ + @PostMapping(value = "/record/{recordId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadAttachment( + @AuthenticationPrincipal User currentUser, + @PathVariable UUID recordId, + @RequestPart("file") MultipartFile file, + @RequestPart("description") String description) throws IOException { + + // Skapar DTO:n internt för att skicka vidare till Service + AttachmentRequest request = new AttachmentRequest(recordId, description); + + AttachmentResponse response = attachmentService.uploadAttachment(currentUser, file, request); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + /** + * Krav: GET /api/attachments/record/{recordId} + * Listar alla bilagor för en specifik journal. + */ + @GetMapping("/record/{recordId}") + public ResponseEntity> listAttachments( + @AuthenticationPrincipal User currentUser, + @PathVariable UUID recordId) { + + List responses = attachmentService.getAttachmentsByRecord(currentUser, recordId); + return ResponseEntity.ok(responses); + } + + /** + * Krav: GET /api/attachments/{id}/download + * Hämtar metadata och en presigned S3-länk för nedladdning. + */ + @GetMapping("/{id}/download") + public ResponseEntity downloadAttachment( + @AuthenticationPrincipal User currentUser, + @PathVariable UUID id) { + + AttachmentResponse response = attachmentService.getAttachment(currentUser, id); + return ResponseEntity.ok(response); + } + + /** + * Krav: DELETE /api/attachments/{id} + * Tar bort metadata i DB och filen i S3. + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteAttachment( + @AuthenticationPrincipal User currentUser, + @PathVariable UUID id) { + + attachmentService.deleteAttachment(currentUser, id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/vet1177/repository/AttachmentRepository.java b/src/main/java/org/example/vet1177/repository/AttachmentRepository.java index 7c5e46f..9a85a72 100644 --- a/src/main/java/org/example/vet1177/repository/AttachmentRepository.java +++ b/src/main/java/org/example/vet1177/repository/AttachmentRepository.java @@ -11,7 +11,7 @@ @Repository public interface AttachmentRepository extends JpaRepository { - List findByMedicalRecordId(UUID recordId); + List findByMedicalRecordId(UUID medicalRecordId); List findByUploadedById(UUID userId); diff --git a/src/main/java/org/example/vet1177/services/AttachmentService.java b/src/main/java/org/example/vet1177/services/AttachmentService.java index 1494f06..6f0280c 100644 --- a/src/main/java/org/example/vet1177/services/AttachmentService.java +++ b/src/main/java/org/example/vet1177/services/AttachmentService.java @@ -19,6 +19,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.List; import java.util.UUID; @Service @@ -64,7 +65,7 @@ public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file, String s3Key = String.format("records/%s/%s_%s", record.getId(), UUID.randomUUID(), - file.getOriginalFilename()); + sanitizedName); // Anropa FileStorageService try { @@ -163,6 +164,17 @@ public void afterCommit() { log.info("Attachment {} marked for deletion in database", attachmentId); } + @Transactional(readOnly = true) + public List getAttachmentsByRecord(User currentUser, UUID recordId) { + MedicalRecord record = medicalRecordRepository.findById(recordId) + .orElseThrow(() -> new ResourceNotFoundException("MedicalRecord", recordId)); + + + return attachmentRepository.findByMedicalRecordId(recordId).stream() + .map(this::mapToResponse) + .toList(); + } + private AttachmentResponse mapToResponse(Attachment attachment) { String downloadUrl = fileStorageService.generatePresignedUrl(attachment.getS3Key()); From 63500487413fadc10a7330368d0a28a641e8454a Mon Sep 17 00:00:00 2001 From: Johan Briger Date: Sun, 5 Apr 2026 19:20:37 +0200 Subject: [PATCH 2/5] feat: add authorization checks to AttachmentController --- .../example/vet1177/services/AttachmentService.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/vet1177/services/AttachmentService.java b/src/main/java/org/example/vet1177/services/AttachmentService.java index 6f0280c..50dfcf2 100644 --- a/src/main/java/org/example/vet1177/services/AttachmentService.java +++ b/src/main/java/org/example/vet1177/services/AttachmentService.java @@ -8,6 +8,7 @@ import org.example.vet1177.entities.User; import org.example.vet1177.exception.ResourceNotFoundException; import org.example.vet1177.policy.AttachmentPolicy; +import org.example.vet1177.policy.MedicalRecordPolicy; import org.example.vet1177.repository.AttachmentRepository; import org.example.vet1177.repository.MedicalRecordRepository; import org.slf4j.Logger; @@ -17,7 +18,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; - +import org.springframework.security.access.AccessDeniedException; import java.io.IOException; import java.util.List; import java.util.UUID; @@ -32,17 +33,20 @@ public class AttachmentService { private final MedicalRecordRepository medicalRecordRepository; private final AttachmentPolicy attachmentPolicy; private final String bucketName; + private final MedicalRecordPolicy medicalRecordPolicy; public AttachmentService(AttachmentRepository attachmentRepository, FileStorageService fileStorageService, MedicalRecordRepository medicalRecordRepository, AttachmentPolicy attachmentPolicy, - AwsS3Properties props) { + AwsS3Properties props, + MedicalRecordPolicy medicalRecordPolicy) { this.attachmentRepository = attachmentRepository; this.fileStorageService = fileStorageService; this.medicalRecordRepository = medicalRecordRepository; this.attachmentPolicy = attachmentPolicy; this.bucketName = props.getBucketName(); + this.medicalRecordPolicy = medicalRecordPolicy; } @@ -169,6 +173,8 @@ public List getAttachmentsByRecord(User currentUser, UUID re MedicalRecord record = medicalRecordRepository.findById(recordId) .orElseThrow(() -> new ResourceNotFoundException("MedicalRecord", recordId)); + medicalRecordPolicy.canView(currentUser, record); + return attachmentRepository.findByMedicalRecordId(recordId).stream() .map(this::mapToResponse) From cb2f9bbb9169766e737854e08c71e3b9a6e4e26c Mon Sep 17 00:00:00 2001 From: Johan Briger Date: Sun, 5 Apr 2026 19:35:44 +0200 Subject: [PATCH 3/5] refactor: handle IOExceptions and enhance security in AttachmentService --- .../controller/AttachmentController.java | 25 +++++-------------- .../vet1177/services/AttachmentService.java | 19 +++++++------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/example/vet1177/controller/AttachmentController.java b/src/main/java/org/example/vet1177/controller/AttachmentController.java index 4d415ee..acbbe12 100644 --- a/src/main/java/org/example/vet1177/controller/AttachmentController.java +++ b/src/main/java/org/example/vet1177/controller/AttachmentController.java @@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.util.List; import java.util.UUID; @@ -26,28 +25,22 @@ public AttachmentController(AttachmentService attachmentService) { this.attachmentService = attachmentService; } - /** - * Krav: POST /api/attachments/record/{recordId} - * Laddar upp en fil kopplad till en specifik journal. - */ + @PostMapping(value = "/record/{recordId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity uploadAttachment( @AuthenticationPrincipal User currentUser, @PathVariable UUID recordId, @RequestPart("file") MultipartFile file, - @RequestPart("description") String description) throws IOException { + @RequestPart("description") String description) { + - // Skapar DTO:n internt för att skicka vidare till Service AttachmentRequest request = new AttachmentRequest(recordId, description); AttachmentResponse response = attachmentService.uploadAttachment(currentUser, file, request); return new ResponseEntity<>(response, HttpStatus.CREATED); } - /** - * Krav: GET /api/attachments/record/{recordId} - * Listar alla bilagor för en specifik journal. - */ + @GetMapping("/record/{recordId}") public ResponseEntity> listAttachments( @AuthenticationPrincipal User currentUser, @@ -57,10 +50,7 @@ public ResponseEntity> listAttachments( return ResponseEntity.ok(responses); } - /** - * Krav: GET /api/attachments/{id}/download - * Hämtar metadata och en presigned S3-länk för nedladdning. - */ + @GetMapping("/{id}/download") public ResponseEntity downloadAttachment( @AuthenticationPrincipal User currentUser, @@ -70,10 +60,7 @@ public ResponseEntity downloadAttachment( return ResponseEntity.ok(response); } - /** - * Krav: DELETE /api/attachments/{id} - * Tar bort metadata i DB och filen i S3. - */ + @DeleteMapping("/{id}") public ResponseEntity deleteAttachment( @AuthenticationPrincipal User currentUser, diff --git a/src/main/java/org/example/vet1177/services/AttachmentService.java b/src/main/java/org/example/vet1177/services/AttachmentService.java index 50dfcf2..9724bde 100644 --- a/src/main/java/org/example/vet1177/services/AttachmentService.java +++ b/src/main/java/org/example/vet1177/services/AttachmentService.java @@ -51,7 +51,7 @@ public AttachmentService(AttachmentRepository attachmentRepository, @Transactional(rollbackFor = Exception.class) - public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file, AttachmentRequest request) throws IOException { + public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file, AttachmentRequest request) { MedicalRecord record = medicalRecordRepository.findById(request.recordId()) .orElseThrow(() -> new ResourceNotFoundException("MedicalRecord", request.recordId())); @@ -74,9 +74,12 @@ public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file, // Anropa FileStorageService try { fileStorageService.upload(s3Key, file.getInputStream(), file.getSize(), file.getContentType()); + } catch (IOException e) { + log.error("Kritisk IO-fel vid läsning av MultipartFile: {}", originalName, e); + throw new RuntimeException("Kunde inte läsa den uppladdade filen. Försök igen.", e); } catch (Exception e) { - log.error("S3 upload failed for key: {}", s3Key); - throw new RuntimeException("Kunde inte ladda upp filen till lagringen", e); + log.error("S3 upload failed for key: {}", s3Key, e); + throw new RuntimeException("Kunde inte ladda upp filen till molnlagringen", e); } // Skapa entitet @@ -98,17 +101,15 @@ public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file, return mapToResponse(attachment); } catch (Exception e) { - // Tack vare saveAndFlush hamnar vi här om databasen nekar sparningen - log.error("Database persistence failed for attachment with S3 key: {}. Triggering S3 cleanup.", s3Key); + log.error("Database persistence failed for S3 key: {}. Triggering cleanup.", s3Key); try { fileStorageService.delete(s3Key); } catch (Exception deleteEx) { - log.error("CRITICAL: Failed to cleanup S3 object {} after DB failure!", s3Key, deleteEx); - } - - throw new RuntimeException("Kunde inte spara bilagans metadata. Uppladdningen avbröts.", e); + log.error("CRITICAL: Failed to cleanup S3 object {}!", s3Key, deleteEx); } + throw new RuntimeException("Kunde inte spara metadata i databasen. Uppladdningen avbröts.", e); + } } private String sanitizeFilename(String originalFilename) { From b6f58f417112a731aa923006e166d302d0132dfa Mon Sep 17 00:00:00 2001 From: Johan Briger Date: Sun, 5 Apr 2026 19:49:36 +0200 Subject: [PATCH 4/5] fix: make attachment description optional and enforce DTO validation --- .../controller/AttachmentController.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/vet1177/controller/AttachmentController.java b/src/main/java/org/example/vet1177/controller/AttachmentController.java index acbbe12..75aa0b0 100644 --- a/src/main/java/org/example/vet1177/controller/AttachmentController.java +++ b/src/main/java/org/example/vet1177/controller/AttachmentController.java @@ -1,6 +1,8 @@ package org.example.vet1177.controller; - +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; import org.example.vet1177.dto.request.attachment.AttachmentRequest; import org.example.vet1177.dto.response.attachment.AttachmentResponse; import org.example.vet1177.entities.User; @@ -11,7 +13,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; - import java.util.List; import java.util.UUID; @@ -20,9 +21,13 @@ public class AttachmentController { private final AttachmentService attachmentService; + private final Validator validator; + + public AttachmentController(AttachmentService attachmentService, Validator validator) { - public AttachmentController(AttachmentService attachmentService) { this.attachmentService = attachmentService; + this.validator = validator; + } @@ -31,11 +36,16 @@ public ResponseEntity uploadAttachment( @AuthenticationPrincipal User currentUser, @PathVariable UUID recordId, @RequestPart("file") MultipartFile file, - @RequestPart("description") String description) { + @RequestPart(value = "description", required = false) String description) { AttachmentRequest request = new AttachmentRequest(recordId, description); + var violations = validator.validate(request); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + AttachmentResponse response = attachmentService.uploadAttachment(currentUser, file, request); return new ResponseEntity<>(response, HttpStatus.CREATED); } From af29edbf031c7d7a1fb7253852e6355eda8ae835 Mon Sep 17 00:00:00 2001 From: Johan Briger Date: Sun, 5 Apr 2026 20:03:04 +0200 Subject: [PATCH 5/5] fix: handle ConstraintViolationException in GlobalExceptionHandler --- .../exception/GlobalExceptionHandler.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/main/java/org/example/vet1177/exception/GlobalExceptionHandler.java b/src/main/java/org/example/vet1177/exception/GlobalExceptionHandler.java index 58b7064..cf62f5c 100644 --- a/src/main/java/org/example/vet1177/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/example/vet1177/exception/GlobalExceptionHandler.java @@ -1,6 +1,8 @@ package org.example.vet1177.exception; +import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; @@ -77,4 +79,26 @@ public ErrorResponse handleGeneric(Exception ex) { return new ErrorResponse(500, "Something went wrong", null); } + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + Map> validationErrors = new HashMap<>(); + + ex.getConstraintViolations().forEach(violation -> { + String fieldName = violation.getPropertyPath().toString(); + String errorMessage = violation.getMessage(); + + validationErrors.computeIfAbsent(fieldName, k -> new ArrayList<>()) + .add(errorMessage); + }); + + ErrorResponse error = new ErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Valideringsfel", + validationErrors // <--- Nu matchar vi Map> + ); + + return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); + } } \ No newline at end of file