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
15 changes: 9 additions & 6 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
Expand All @@ -103,12 +111,7 @@
</dependencies>

<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
</resources>

<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/org/example/vet1177/Vet1177Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class Vet1177Application {

public static void main(String[] args) {
Expand Down
61 changes: 61 additions & 0 deletions src/main/java/org/example/vet1177/entities/OrphanedS3Object.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package org.example.vet1177.entities;

import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "orphaned_s3_objects")
public class OrphanedS3Object {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@Column(name = "s3_key", unique = true, nullable = false)
private String s3Key;

@Column(name = "s3_bucket", nullable = false)
private String s3Bucket;

@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt = Instant.now();

@Column(name = "retry_count", nullable = false)
private int retryCount = 0;

@Column(name = "last_attempt_at")
private Instant lastAttemptAt;

@Column(name = "last_error", columnDefinition = "TEXT")
private String lastError;

public OrphanedS3Object() {}

public OrphanedS3Object(String s3Key, String s3Bucket) {
this.s3Key = s3Key;
this.s3Bucket = s3Bucket;
this.createdAt = Instant.now();
this.retryCount = 0;
}

public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }

public String getS3Key() { return s3Key; }
public void setS3Key(String s3Key) { this.s3Key = s3Key; }

public String getS3Bucket() { return s3Bucket; }
public void setS3Bucket(String s3Bucket) { this.s3Bucket = s3Bucket; }

public Instant getCreatedAt() { return createdAt; }

public int getRetryCount() { return retryCount; }
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }

public Instant getLastAttemptAt() { return lastAttemptAt; }
public void setLastAttemptAt(Instant lastAttemptAt) { this.lastAttemptAt = lastAttemptAt; }

public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.example.vet1177.repository;

import org.example.vet1177.entities.OrphanedS3Object;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface OrphanedS3ObjectRepository extends JpaRepository<OrphanedS3Object, UUID> {

@Query("""
SELECT o FROM OrphanedS3Object o
WHERE o.retryCount < :maxRetries
ORDER BY o.lastAttemptAt ASC NULLS FIRST, o.createdAt ASC
""")

List<OrphanedS3Object> findNextBatchToProcess(@Param("maxRetries") int maxRetries, Pageable pageable);

long countByRetryCountGreaterThanEqual(int maxRetries);

Optional<OrphanedS3Object> findByS3Key(String s3Key);
}
140 changes: 67 additions & 73 deletions src/main/java/org/example/vet1177/services/AttachmentService.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,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;
Expand All @@ -34,44 +34,43 @@ public class AttachmentService {
private final AttachmentPolicy attachmentPolicy;
private final String bucketName;
private final MedicalRecordPolicy medicalRecordPolicy;
private final OrphanedS3Enqueuer orphanedS3Enqueuer;

public AttachmentService(AttachmentRepository attachmentRepository,
FileStorageService fileStorageService,
MedicalRecordRepository medicalRecordRepository,
AttachmentPolicy attachmentPolicy,
AwsS3Properties props,
MedicalRecordPolicy medicalRecordPolicy) {
MedicalRecordPolicy medicalRecordPolicy,
OrphanedS3Enqueuer orphanedS3Enqueuer) {
this.attachmentRepository = attachmentRepository;
this.fileStorageService = fileStorageService;
this.medicalRecordRepository = medicalRecordRepository;
this.attachmentPolicy = attachmentPolicy;
this.bucketName = props.getBucketName();
this.medicalRecordPolicy = medicalRecordPolicy;
this.orphanedS3Enqueuer = orphanedS3Enqueuer;
}


@Transactional(rollbackFor = Exception.class)
public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file, AttachmentRequest request) {
public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file, AttachmentRequest request) {
MedicalRecord record = medicalRecordRepository.findById(request.recordId())
.orElseThrow(() -> new ResourceNotFoundException("MedicalRecord", request.recordId()));

if (file.isEmpty() || file.getSize() <= 0) {
throw new IllegalArgumentException("Det går inte att ladda upp en tom fil.");
}

// Validering
attachmentPolicy.canUpload(currentUser, record, file.getContentType(), file.getSize());

String originalName = file.getOriginalFilename();
String sanitizedName = sanitizeFilename(originalName);

// Skapa unik S3-nyckel
String s3Key = String.format("records/%s/%s_%s",
record.getId(),
UUID.randomUUID(),
sanitizedName);

// Anropa FileStorageService
try {
fileStorageService.upload(s3Key, file.getInputStream(), file.getSize(), file.getContentType());
} catch (IOException e) {
Expand All @@ -82,63 +81,40 @@ public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file,
throw new RuntimeException("Kunde inte ladda upp filen till molnlagringen", e);
}

// Skapa entitet
try {
Attachment attachment = new Attachment();
attachment.setMedicalRecord(record);
attachment.setUploadedBy(currentUser);
attachment.setFileName(file.getOriginalFilename());
attachment.setS3Key(s3Key);
attachment.setS3Bucket(bucketName);
attachment.setFileType(file.getContentType());
attachment.setFileSizeBytes(file.getSize());
attachment.setDescription(request.description());

attachment = attachmentRepository.saveAndFlush(attachment);

log.info("Attachment {} successfully persisted for record {}", attachment.getId(), record.getId());

return mapToResponse(attachment);

} catch (Exception e) {
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 {}!", s3Key, deleteEx);
}
throw new RuntimeException("Kunde inte spara metadata i databasen. Uppladdningen avbröts.", e);
}
}
Attachment attachment = new Attachment();
attachment.setMedicalRecord(record);
attachment.setUploadedBy(currentUser);
attachment.setFileName(file.getOriginalFilename());
attachment.setS3Key(s3Key);
attachment.setS3Bucket(bucketName);
attachment.setFileType(file.getContentType());
attachment.setFileSizeBytes(file.getSize());
attachment.setDescription(request.description());

private String sanitizeFilename(String originalFilename) {
if (originalFilename == null || originalFilename.isBlank()) {
return UUID.randomUUID().toString();
}
attachment = attachmentRepository.saveAndFlush(attachment);

String filename = new java.io.File(originalFilename).getName();
filename = filename.replaceAll("[^a-zA-Z0-9\\.\\-_]", "_");
log.info("Attachment {} successfully persisted for record {}", attachment.getId(), record.getId());
return mapToResponse(attachment);

filename = filename.trim();
if (filename.isEmpty() || filename.equals(".") || filename.equals("..")) {
return "file_" + System.currentTimeMillis();
} catch (Exception e) {
// FIX: Inkluderar stacktrace (e) och bevarar felkedjan
log.error("Database persistence failed for S3 key: {}. Triggering cleanup.", s3Key, e);
try {
fileStorageService.delete(s3Key);
} catch (Exception deleteEx) {
// FIX: Loggar cleanup-felet separat och skapar en informativ anledning för kön
log.error("Upload cleanup failed for {}. Enqueuing for background retry.", s3Key, deleteEx);

String errorReason = String.format("DB Error: %s. Cleanup Error: %s",
e.getClass().getSimpleName(), deleteEx.getMessage());

orphanedS3Enqueuer.enqueue(s3Key, bucketName, errorReason);
}
return filename;
}


@Transactional(readOnly = true)
public AttachmentResponse getAttachment(User currentUser, UUID attachmentId) {
Attachment attachment = attachmentRepository.findById(attachmentId)
.orElseThrow(() -> new ResourceNotFoundException("Attachment", attachmentId));

attachmentPolicy.canDownload(currentUser, attachment);

return mapToResponse(attachment);
throw new RuntimeException("Kunde inte spara metadata i databasen. Uppladdningen avbröts.", e);
}
Comment thread
johanbriger marked this conversation as resolved.
}


@Transactional
public void deleteAttachment(User currentUser, UUID attachmentId) {
Attachment attachment = attachmentRepository.findById(attachmentId)
Expand All @@ -147,44 +123,62 @@ public void deleteAttachment(User currentUser, UUID attachmentId) {
attachmentPolicy.canDelete(currentUser, attachment);

String s3Key = attachment.getS3Key();
String s3Bucket = attachment.getS3Bucket();

attachmentRepository.delete(attachment);

if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
fileStorageService.delete(s3Key);
log.info("S3 object {} deleted after successful DB commit", s3Key);
} catch (Exception e) {
log.error("CRITICAL: Failed to delete S3 object {} after DB commit!", s3Key, e);
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
fileStorageService.delete(s3Key);
log.info("S3 object {} deleted after successful DB commit", s3Key);
} catch (Exception e) {
log.error("S3 deletion failed for {}. Enqueuing for background retry.", s3Key, e);
orphanedS3Enqueuer.enqueue(s3Key, s3Bucket, "S3 deletion failed after commit: " + e.getMessage());
}
});
} else {
fileStorageService.delete(s3Key);
}
}
});

log.info("Attachment {} marked for deletion in database", attachmentId);
}

@Transactional(readOnly = true)
public AttachmentResponse getAttachment(User currentUser, UUID attachmentId) {
Attachment attachment = attachmentRepository.findById(attachmentId)
.orElseThrow(() -> new ResourceNotFoundException("Attachment", attachmentId));

attachmentPolicy.canDownload(currentUser, attachment);
return mapToResponse(attachment);
}

@Transactional(readOnly = true)
public List<AttachmentResponse> getAttachmentsByRecord(User currentUser, UUID recordId) {
MedicalRecord record = medicalRecordRepository.findById(recordId)
.orElseThrow(() -> new ResourceNotFoundException("MedicalRecord", recordId));

medicalRecordPolicy.canView(currentUser, record);


return attachmentRepository.findByMedicalRecordId(recordId).stream()
.map(this::mapToResponse)
.toList();
}

private String sanitizeFilename(String originalFilename) {
if (originalFilename == null || originalFilename.isBlank()) {
return UUID.randomUUID().toString();
}
String filename = new java.io.File(originalFilename).getName();
filename = filename.replaceAll("[^a-zA-Z0-9.\\-_]", "_");
filename = filename.trim();
if (filename.isEmpty() || filename.equals(".") || filename.equals("..")) {
return "file_" + System.currentTimeMillis();
}
return filename;
}

private AttachmentResponse mapToResponse(Attachment attachment) {
String downloadUrl = fileStorageService.generatePresignedUrl(attachment.getS3Key());

return new AttachmentResponse(
attachment.getId(),
attachment.getMedicalRecord().getId(),
Expand Down
Loading