diff --git a/pom.xml b/pom.xml
index 72bc9824..9dc24003 100644
--- a/pom.xml
+++ b/pom.xml
@@ -81,6 +81,14 @@
org.springframework.boot
spring-boot-starter-security
+
+ org.springframework.boot
+ spring-boot-starter-flyway
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
org.springframework.boot
spring-boot-starter-oauth2-resource-server
@@ -103,12 +111,7 @@
-
-
- src/main/resources
- false
-
-
+
org.springframework.boot
diff --git a/src/main/java/org/example/vet1177/Vet1177Application.java b/src/main/java/org/example/vet1177/Vet1177Application.java
index 8a271aeb..fdc032e6 100644
--- a/src/main/java/org/example/vet1177/Vet1177Application.java
+++ b/src/main/java/org/example/vet1177/Vet1177Application.java
@@ -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) {
diff --git a/src/main/java/org/example/vet1177/entities/OrphanedS3Object.java b/src/main/java/org/example/vet1177/entities/OrphanedS3Object.java
new file mode 100644
index 00000000..12037d28
--- /dev/null
+++ b/src/main/java/org/example/vet1177/entities/OrphanedS3Object.java
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java b/src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java
new file mode 100644
index 00000000..4e0236a4
--- /dev/null
+++ b/src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java
@@ -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 {
+
+ @Query("""
+ SELECT o FROM OrphanedS3Object o
+ WHERE o.retryCount < :maxRetries
+ ORDER BY o.lastAttemptAt ASC NULLS FIRST, o.createdAt ASC
+ """)
+
+ List findNextBatchToProcess(@Param("maxRetries") int maxRetries, Pageable pageable);
+
+ long countByRetryCountGreaterThanEqual(int maxRetries);
+
+ Optional findByS3Key(String s3Key);
+}
diff --git a/src/main/java/org/example/vet1177/services/AttachmentService.java b/src/main/java/org/example/vet1177/services/AttachmentService.java
index 9724bde6..2ccff9a3 100644
--- a/src/main/java/org/example/vet1177/services/AttachmentService.java
+++ b/src/main/java/org/example/vet1177/services/AttachmentService.java
@@ -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;
@@ -34,24 +34,26 @@ 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()));
@@ -59,19 +61,16 @@ public AttachmentResponse uploadAttachment(User currentUser, MultipartFile file,
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) {
@@ -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);
+ }
}
-
@Transactional
public void deleteAttachment(User currentUser, UUID attachmentId) {
Attachment attachment = attachmentRepository.findById(attachmentId)
@@ -147,28 +123,35 @@ 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 getAttachmentsByRecord(User currentUser, UUID recordId) {
MedicalRecord record = medicalRecordRepository.findById(recordId)
@@ -176,15 +159,26 @@ public List getAttachmentsByRecord(User currentUser, UUID re
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(),
diff --git a/src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java b/src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java
new file mode 100644
index 00000000..31e1929f
--- /dev/null
+++ b/src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java
@@ -0,0 +1,53 @@
+package org.example.vet1177.services;
+
+import org.example.vet1177.entities.OrphanedS3Object;
+import org.example.vet1177.repository.OrphanedS3ObjectRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+public class OrphanedS3CleanupWorker {
+
+ private static final Logger log = LoggerFactory.getLogger(OrphanedS3CleanupWorker.class);
+
+ private final OrphanedS3ObjectRepository repository;
+ private final OrphanedS3Processor processor;
+
+ private static final int MAX_RETRIES = 10;
+
+ public OrphanedS3CleanupWorker(OrphanedS3ObjectRepository repository,
+ OrphanedS3Processor processor) {
+ this.repository = repository;
+ this.processor = processor;
+ }
+
+ @Scheduled(fixedDelay = 600000) // 10 minuter
+ public void retryDeletions() {
+ List pending = repository.findNextBatchToProcess(
+ MAX_RETRIES,
+ PageRequest.of(0, 20)
+ );
+
+ // Processera batchen om den inte är tom
+ if (!pending.isEmpty()) {
+ log.info("Starting background cleanup of {} orphaned S3 objects", pending.size());
+ for (OrphanedS3Object orphan : pending) {
+ processor.processOrphan(orphan, MAX_RETRIES);
+ }
+ }
+
+ // ALERT-LOGIK: Kontrollera om det finns objekt som har misslyckats permanent
+ // (retry_count >= MAX_RETRIES). Dessa dyker inte upp i 'pending' ovan.
+ long permanentFailures = repository.countByRetryCountGreaterThanEqual(MAX_RETRIES);
+ if (permanentFailures > 0) {
+ log.error("ALERT: {} orphaned S3 objects have exceeded MAX_RETRIES={}. " +
+ "These objects will no longer be retried and require manual cleanup in S3.",
+ permanentFailures, MAX_RETRIES);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java b/src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java
new file mode 100644
index 00000000..1f92ab1d
--- /dev/null
+++ b/src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java
@@ -0,0 +1,58 @@
+package org.example.vet1177.services;
+
+import org.example.vet1177.entities.OrphanedS3Object;
+import org.example.vet1177.repository.OrphanedS3ObjectRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+
+@Service
+public class OrphanedS3Enqueuer {
+
+ private static final Logger log = LoggerFactory.getLogger(OrphanedS3Enqueuer.class);
+ private final OrphanedS3ObjectRepository repository;
+
+ public OrphanedS3Enqueuer(OrphanedS3ObjectRepository repository) {
+ this.repository = repository;
+ }
+
+ /**
+ * REQUIRES_NEW tvingar Spring att pausa den nuvarande transaktionen
+ * och starta en helt ny, oberoende transaktion för just denna sparning.
+ * Metoden är nu idempotent (find-or-create).
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void enqueue(String s3Key, String bucket, String reason) {
+
+ // Förbered det trunkerade felmeddelandet
+ String truncatedReason = null;
+ if (reason != null) {
+ truncatedReason = reason.length() > 1024
+ ? reason.substring(0, 1021) + "..."
+ : reason;
+ }
+
+ final String finalReason = truncatedReason;
+
+ // "Upsert"-logik: Hitta befintlig eller skapa ny
+ repository.findByS3Key(s3Key).ifPresentOrElse(
+ existing -> {
+ log.debug("Orphaned S3 key already exists: {}. Updating error info.", s3Key);
+ existing.setLastError(finalReason);
+ existing.setLastAttemptAt(null); // Återställ så att Worker kan plocka upp den direkt
+ repository.save(existing);
+ },
+ () -> {
+ log.info("Enqueuing new orphaned S3 object: {}", s3Key);
+ OrphanedS3Object newOrphan = new OrphanedS3Object(s3Key, bucket);
+ newOrphan.setLastError(finalReason);
+ newOrphan.setLastAttemptAt(null);
+ repository.save(newOrphan);
+ }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/example/vet1177/services/OrphanedS3Processor.java b/src/main/java/org/example/vet1177/services/OrphanedS3Processor.java
new file mode 100644
index 00000000..97043a61
--- /dev/null
+++ b/src/main/java/org/example/vet1177/services/OrphanedS3Processor.java
@@ -0,0 +1,52 @@
+package org.example.vet1177.services;
+
+import org.example.vet1177.entities.OrphanedS3Object;
+import org.example.vet1177.repository.OrphanedS3ObjectRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+
+@Component
+public class OrphanedS3Processor {
+
+ private static final Logger log = LoggerFactory.getLogger(OrphanedS3Processor.class);
+ private final OrphanedS3ObjectRepository repository;
+ private final FileStorageService fileStorageService;
+
+ public OrphanedS3Processor(OrphanedS3ObjectRepository repository,
+ FileStorageService fileStorageService) {
+ this.repository = repository;
+ this.fileStorageService = fileStorageService;
+ }
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void processOrphan(OrphanedS3Object orphan, int maxRetries) {
+ try {
+ // Försök radera från S3/MinIO
+ fileStorageService.delete(orphan.getS3Key());
+
+ // Ta bort från kön vid framgång
+ repository.delete(orphan);
+ log.info("Successfully cleaned up orphaned S3 object: {}", orphan.getS3Key());
+
+ } catch (Exception e) {
+ // Vid fel: uppdatera räknare och felmeddelande i en egen transaktion
+ orphan.setRetryCount(orphan.getRetryCount() + 1);
+ orphan.setLastAttemptAt(Instant.now());
+
+ String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error during retry";
+ if (errorMessage.length() > 1024) {
+ errorMessage = errorMessage.substring(0, 1021) + "...";
+ }
+ orphan.setLastError(errorMessage);
+
+ repository.save(orphan);
+ log.warn("Retry failed for S3 object {}. Attempt {}/{}",
+ orphan.getS3Key(), orphan.getRetryCount(), maxRetries, e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties
index 0ba6bd55..084e016e 100644
--- a/src/main/resources/application-dev.properties
+++ b/src/main/resources/application-dev.properties
@@ -3,15 +3,18 @@
# Aktiveras av spring.profiles.active=dev
# ============================================
-# Databas ? pekar på lokal Docker-databas
+# Databas ? pekar p lokal Docker-databas
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
-# Visa mer SQL-info i dev
+# JPA / Hibernate / Data.sql
+spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
+spring.jpa.defer-datasource-initialization=true
+spring.sql.init.mode=never
# MinIO ? lokal instans via Docker
aws.s3.endpoint=http://localhost:9000
@@ -20,8 +23,7 @@ aws.s3.secret-key=minioadmin
aws.s3.bucket-name=vet1177-attachments
aws.s3.region=eu-north-1
-# Schema och testdata laddas automatiskt vid uppstart
-spring.sql.init.mode=always
-spring.sql.init.schema-locations=classpath:schema.sql
-spring.sql.init.platform=postgresql
-spring.sql.init.data-locations=classpath:data.sql
\ No newline at end of file
+# Flyway
+spring.flyway.enabled=true
+spring.flyway.baseline-on-migrate=true
+spring.flyway.locations=classpath:db/migration,classpath:db/migration/dev
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V1__initial_schema.sql b/src/main/resources/db/migration/V1__initial_schema.sql
new file mode 100644
index 00000000..e48b7eb8
--- /dev/null
+++ b/src/main/resources/db/migration/V1__initial_schema.sql
@@ -0,0 +1,107 @@
+CREATE TABLE IF NOT EXISTS clinic (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ address VARCHAR(500) NOT NULL,
+ phone_number VARCHAR(30),
+ email VARCHAR(255),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'OWNER',
+ clinic_id UUID REFERENCES clinic(id),
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS vet_details (
+ user_id UUID PRIMARY KEY REFERENCES users(id),
+ license_id VARCHAR(50) NOT NULL UNIQUE,
+ specialization VARCHAR(255),
+ booking_info VARCHAR(500)
+);
+
+CREATE TABLE IF NOT EXISTS pet (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL REFERENCES users(id),
+ clinic_id UUID REFERENCES clinic(id),
+ name VARCHAR(255) NOT NULL,
+ species VARCHAR(100) NOT NULL,
+ breed VARCHAR(255),
+ date_of_birth DATE,
+ weight_kg DECIMAL(6,2),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS medical_record (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
+ pet_id UUID NOT NULL REFERENCES pet(id),
+ owner_id UUID NOT NULL REFERENCES users(id),
+ clinic_id UUID NOT NULL REFERENCES clinic(id),
+ assigned_vet_id UUID REFERENCES users(id),
+ created_by UUID NOT NULL REFERENCES users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_by UUID REFERENCES users(id),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ closed_at TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS comment (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL REFERENCES medical_record(id),
+ author_id UUID NOT NULL REFERENCES users(id),
+ body TEXT NOT NULL,
+ comment_type VARCHAR(32) NOT NULL DEFAULT 'OWNER_MESSAGE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+ALTER TABLE comment ADD COLUMN IF NOT EXISTS comment_type VARCHAR(32) NOT NULL DEFAULT 'OWNER_MESSAGE';
+
+CREATE TABLE IF NOT EXISTS attachment (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID NOT NULL REFERENCES medical_record(id) ON DELETE CASCADE,
+ uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
+ file_name VARCHAR(500) NOT NULL,
+ description VARCHAR(500),
+ s3_key VARCHAR(1000) NOT NULL UNIQUE,
+ s3_bucket VARCHAR(255) NOT NULL,
+ file_type VARCHAR(100),
+ file_size_bytes BIGINT,
+ uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Migration för befintliga databaser som skapades innan description-kolumnen lades till.
+-- CREATE TABLE IF NOT EXISTS ovan är no-op om tabellen redan finns, så kolumnen måste
+-- läggas till explicit. Samma mönster som comment_type ovan.
+ALTER TABLE attachment ADD COLUMN IF NOT EXISTS description VARCHAR(500);
+
+CREATE TABLE IF NOT EXISTS activity_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ record_id UUID REFERENCES medical_record(id),
+ action VARCHAR(50) NOT NULL,
+ entity_type VARCHAR(50) NOT NULL,
+ entity_id UUID,
+ performed_by UUID NOT NULL REFERENCES users(id),
+ details TEXT,
+ performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Index
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+CREATE INDEX IF NOT EXISTS idx_users_clinic ON users(clinic_id);
+CREATE INDEX IF NOT EXISTS idx_record_pet ON medical_record(pet_id);
+CREATE INDEX IF NOT EXISTS idx_record_status ON medical_record(status);
+CREATE INDEX IF NOT EXISTS idx_comment_record ON comment(record_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_attachment_record ON attachment(record_id);
+CREATE INDEX IF NOT EXISTS idx_log_record ON activity_log(record_id, performed_at);
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V2__init_orphaned_table.sql b/src/main/resources/db/migration/V2__init_orphaned_table.sql
new file mode 100644
index 00000000..9d77f11e
--- /dev/null
+++ b/src/main/resources/db/migration/V2__init_orphaned_table.sql
@@ -0,0 +1,14 @@
+-- Skapar tabellen för misslyckade raderingar
+CREATE TABLE orphaned_s3_objects (
+ id UUID PRIMARY KEY,
+ s3_key VARCHAR(512) NOT NULL,
+ s3_bucket VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ retry_count INT NOT NULL DEFAULT 0,
+ last_attempt_at TIMESTAMP WITH TIME ZONE,
+ last_error TEXT,
+ CONSTRAINT uk_orphaned_s3_key UNIQUE (s3_key)
+);
+
+
+CREATE INDEX idx_orphaned_s3_retry ON orphaned_s3_objects (retry_count, last_attempt_at);
\ No newline at end of file
diff --git a/src/main/resources/db/migration/dev/V3__insert_demo_data.sql b/src/main/resources/db/migration/dev/V3__insert_demo_data.sql
new file mode 100644
index 00000000..6d733d5a
--- /dev/null
+++ b/src/main/resources/db/migration/dev/V3__insert_demo_data.sql
@@ -0,0 +1,32 @@
+-- V3__insert_demo_data.sql
+
+-- Klinik
+INSERT INTO clinic (id, name, address, phone_number, email, created_at, updated_at)
+VALUES ('a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', 'Djurkliniken Centrum', 'Storgatan 1, Stockholm', '08-123456', 'centrum@klinik.se', NOW(), NOW());
+
+-- Users
+INSERT INTO users (id, name, email, password_hash, role, clinic_id, is_active, created_at, updated_at)
+VALUES
+ ('c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7', 'Anna Svensson', 'anna@test.se', '$2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy', 'OWNER', NULL, true, NOW(), NOW()),
+ ('b1c2d3e4-f5a6-4b7c-8d9e-f0a1b2c3d4e5', 'Lars Johansson', 'lars@test.se', '$2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy', 'OWNER', NULL, true, NOW(), NOW()),
+ ('d4e5f6a7-b8c9-4d5e-1f2a-b3c4d5e6f7a8', 'Erik Veterinär', 'erik@klinik.se', '$2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy', 'VET', 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', true, NOW(), NOW()),
+ ('e5f6a7b8-c9d0-4e5f-2a3b-c4d5e6f7a8b9', 'Sara Admin', 'sara@admin.se', '$2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy', 'ADMIN', NULL, true, NOW(), NOW());
+
+-- Pets
+INSERT INTO pet (id, owner_id, clinic_id, name, species, breed, weight_kg, created_at, updated_at)
+VALUES
+ ('f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0', 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7', 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', 'Fido', 'Hund', 'Labrador', 28.5, NOW(), NOW()),
+ ('a7b8c9d0-e1f2-4a5b-4c5d-e6f7a8b9c0d1', 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7', 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', 'Missan', 'Katt', 'Persisk', 4.2, NOW(), NOW()),
+ ('c8d9e0f1-a2b3-4c4d-5e6f-a7b8c9d0e1f2', 'b1c2d3e4-f5a6-4b7c-8d9e-f0a1b2c3d4e5', 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', 'Birk', 'Hund', 'Golden Retriever', 32.0, NOW(), NOW()),
+ ('d9e0f1a2-b3c4-4d5e-6f7a-b8c9d0e1f2a3', 'b1c2d3e4-f5a6-4b7c-8d9e-f0a1b2c3d4e5', 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', 'Misse', 'Katt', 'Maine Coon', 6.8, NOW(), NOW());
+
+-- Medical Records
+INSERT INTO medical_record (id, title, description, status, pet_id, owner_id, clinic_id, created_by, created_at, updated_at)
+VALUES
+ ('b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2', 'Fido haltar', 'Hunden har haltat sedan igår.', 'OPEN', 'f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0', 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7', 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7', NOW(), NOW()),
+ ('c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3', 'Missan äter inte', 'Katten har inte ätit på 2 dagar.', 'IN_PROGRESS', 'a7b8c9d0-e1f2-4a5b-4c5d-e6f7a8b9c0d1', 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7', 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5', 'd4e5f6a7-b8c9-4d5e-1f2a-b3c4d5e6f7a8', NOW(), NOW());
+
+-- Comments
+INSERT INTO comment (id, record_id, author_id, body, comment_type, created_at, updated_at)
+VALUES
+ ('a2b3c4d5-e6f7-4a5b-9c0d-e1f2a3b4c5d6', 'c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3', 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7', 'Hon dricker vatten men vägrar all mat.', 'OWNER_MESSAGE', NOW(), NOW());
\ No newline at end of file
diff --git a/src/test/java/org/example/vet1177/services/AttachmentServiceTest.java b/src/test/java/org/example/vet1177/services/AttachmentServiceTest.java
index 80e21945..05024bc9 100644
--- a/src/test/java/org/example/vet1177/services/AttachmentServiceTest.java
+++ b/src/test/java/org/example/vet1177/services/AttachmentServiceTest.java
@@ -12,10 +12,13 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import java.util.Optional;
@@ -47,6 +50,9 @@ class AttachmentServiceTest {
@Mock
private MedicalRecordPolicy medicalRecordPolicy;
+ @Mock
+ private OrphanedS3Enqueuer orphanedS3Enqueuer;
+
private AttachmentService attachmentService;
private User vetUser;
@@ -69,7 +75,9 @@ void setUp() {
medicalRecordRepository,
attachmentPolicy,
props,
- medicalRecordPolicy
+ medicalRecordPolicy,
+ orphanedS3Enqueuer
+
);
vetUser = new User("Dr. Sara Lindqvist", "sara@vet.se", "hash", Role.VET);
@@ -261,29 +269,49 @@ void getByRecord_whenRecordNotFound_shouldThrowResourceNotFoundException() {
@Test
void delete_shouldDeleteFromRepository() {
+ try (var mockedStatic = mockStatic(TransactionSynchronizationManager.class)) {
+ mockedStatic.when(TransactionSynchronizationManager::isSynchronizationActive).thenReturn(true);
when(attachmentRepository.findById(attachmentId)).thenReturn(Optional.of(attachment));
attachmentService.deleteAttachment(vetUser, attachmentId);
verify(attachmentRepository).delete(attachment);
+ mockedStatic.verify(() -> TransactionSynchronizationManager.registerSynchronization(any()));
+ }
}
@Test
void delete_shouldCallPolicyCanDelete() {
+ try (var mockedStatic = mockStatic(TransactionSynchronizationManager.class)) {
+ mockedStatic.when(TransactionSynchronizationManager::isSynchronizationActive).thenReturn(true);
when(attachmentRepository.findById(attachmentId)).thenReturn(Optional.of(attachment));
attachmentService.deleteAttachment(vetUser, attachmentId);
verify(attachmentPolicy).canDelete(vetUser, attachment);
+ mockedStatic.verify(() -> TransactionSynchronizationManager.registerSynchronization(any()));
+ }
+
}
@Test
void delete_shouldCallFileStorageDelete() {
- when(attachmentRepository.findById(attachmentId)).thenReturn(Optional.of(attachment));
+ try (var mockedStatic = mockStatic(TransactionSynchronizationManager.class)) {
+ // Berätta för Spring att transaktionen är aktiv
+ mockedStatic.when(TransactionSynchronizationManager::isSynchronizationActive).thenReturn(true);
- attachmentService.deleteAttachment(vetUser, attachmentId);
+ when(attachmentRepository.findById(attachmentId)).thenReturn(Optional.of(attachment));
- verify(fileStorageService).delete(attachment.getS3Key());
+ ArgumentCaptor syncCaptor = ArgumentCaptor.forClass(TransactionSynchronization.class);
+
+ attachmentService.deleteAttachment(vetUser, attachmentId);
+
+ mockedStatic.verify(() -> TransactionSynchronizationManager.registerSynchronization(syncCaptor.capture()));
+
+ syncCaptor.getValue().afterCommit();
+
+ verify(fileStorageService).delete(attachment.getS3Key());
+ }
}
@Test
@@ -295,4 +323,25 @@ void delete_whenNotFound_shouldThrowResourceNotFoundException() {
verify(attachmentRepository, never()).delete(any());
}
+
+ @Test
+ void upload_whenDbFailsAndS3DeleteFails_shouldEnqueueOrphan() {
+ MockMultipartFile file = new MockMultipartFile(
+ "file", "bild.jpg", "image/jpeg", new byte[]{1, 2, 3});
+
+ when(medicalRecordRepository.findById(recordId)).thenReturn(Optional.of(record));
+
+ // 1. Tvinga DB att krascha
+ when(attachmentRepository.saveAndFlush(any())).thenThrow(new RuntimeException("DB Error"));
+
+ // 2. Tvinga även raderingen att krascha (det är då den hamnar i kön!)
+ doThrow(new RuntimeException("S3 Delete Error"))
+ .when(fileStorageService).delete(anyString());
+
+ assertThatThrownBy(() -> attachmentService.uploadAttachment(vetUser, file, new AttachmentRequest(recordId, "En bild")))
+ .isInstanceOf(RuntimeException.class);
+
+ // Nu kommer kön att anropas!
+ verify(orphanedS3Enqueuer).enqueue(anyString(), eq("test-bucket"), anyString());
+ }
}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
index 0fcfdc88..becc8639 100644
--- a/src/test/resources/application-test.properties
+++ b/src/test/resources/application-test.properties
@@ -36,4 +36,6 @@ jwt.expiration-ms=86400000
# Mindre output i tester
spring.jpa.show-sql=false
-spring.jpa.properties.hibernate.format_sql=false
\ No newline at end of file
+spring.jpa.properties.hibernate.format_sql=false
+
+spring.flyway.enabled=false
\ No newline at end of file