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