diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index b2d14d87..fb52dd7a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; @@ -10,6 +12,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.dto.MatchOffset; import org.raddatz.familienarchiv.dto.SearchMatchData; import org.raddatz.familienarchiv.dto.TagOperator; +import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.ScriptType; @@ -20,6 +23,10 @@ import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.springframework.stereotype.Service; @@ -56,6 +63,8 @@ public class DocumentService { private final TagService tagService; private final DocumentVersionService documentVersionService; private final AnnotationService annotationService; + private final AuditService auditService; + private final UserService userService; public record StoreResult(Document document, boolean isNew) {} @@ -108,11 +117,17 @@ public class DocumentService { document.setFilePath(upload.s3Key()); document.setFileHash(upload.fileHash()); document.setContentType(file.getContentType()); - if (document.getStatus() == DocumentStatus.PLACEHOLDER) { + boolean wasPlaceholder = document.getStatus() == DocumentStatus.PLACEHOLDER; + if (wasPlaceholder) { document.setStatus(DocumentStatus.UPLOADED); } - return new StoreResult(documentRepository.save(document), isNew); + Document saved = documentRepository.save(document); + if (wasPlaceholder) { + UUID actorId = resolveCurrentUserId(); + logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null); + } + return new StoreResult(saved, isNew); } @Transactional @@ -192,6 +207,8 @@ public class DocumentService { Document doc = documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); + DocumentStatus statusBefore = doc.getStatus(); + // 1. Einfache Felder Update doc.setTitle(dto.getTitle()); doc.setDocumentDate(dto.getDocumentDate()); @@ -245,6 +262,15 @@ public class DocumentService { Document saved = documentRepository.save(doc); documentVersionService.recordVersion(saved); + + UUID actorId = resolveCurrentUserId(); + if (saved.getStatus() != statusBefore) { + logAfterCommit(AuditKind.STATUS_CHANGED, actorId, saved.getId(), + Map.of("oldStatus", statusBefore.name(), "newStatus", saved.getStatus().name())); + } else { + logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null); + } + return saved; } @@ -300,11 +326,16 @@ public class DocumentService { doc.setFileHash(upload.fileHash()); doc.setOriginalFilename(file.getOriginalFilename()); doc.setContentType(file.getContentType()); - if (doc.getStatus() == DocumentStatus.PLACEHOLDER) { + boolean wasPlaceholder = doc.getStatus() == DocumentStatus.PLACEHOLDER; + if (wasPlaceholder) { doc.setStatus(DocumentStatus.UPLOADED); } Document saved = documentRepository.save(doc); documentVersionService.recordVersion(saved); + if (wasPlaceholder) { + UUID actorId = resolveCurrentUserId(); + logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null); + } return saved; } @@ -725,4 +756,26 @@ public class DocumentService { throw new IllegalStateException("SHA-256 not available", e); } } + + private UUID resolveCurrentUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) return null; + String email = auth.getName(); + if (email == null) return null; + AppUser user = userService.findByEmail(email); + return user != null ? user.getId() : null; + } + + private void logAfterCommit(AuditKind kind, UUID actorId, UUID documentId, Map payload) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + auditService.log(kind, actorId, documentId, payload); + } + }); + } else { + auditService.log(kind, actorId, documentId, payload); + } + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 69e127e2..32f7e427 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -6,6 +6,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; @@ -13,6 +15,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.dto.MatchOffset; import org.raddatz.familienarchiv.dto.SearchMatchData; import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; @@ -36,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -47,6 +51,8 @@ class DocumentServiceTest { @Mock TagService tagService; @Mock DocumentVersionService documentVersionService; @Mock AnnotationService annotationService; + @Mock AuditService auditService; + @Mock UserService userService; @InjectMocks DocumentService documentService; // ─── deleteDocument ─────────────────────────────────────────────────────── @@ -1499,4 +1505,86 @@ class DocumentServiceTest { assertThatThrownBy(() -> documentService.attachFile(id, file)) .isInstanceOf(DomainException.class); } + + // ─── audit events ───────────────────────────────────────────────────────── + + @Test + void updateDocument_logsMetadataUpdated_whenNoStatusChange() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).status(DocumentStatus.UPLOADED).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.updateDocument(id, new DocumentUpdateDTO(), null); + + verify(auditService).log(eq(AuditKind.METADATA_UPDATED), isNull(), eq(id), isNull()); + verify(auditService, never()).log(eq(AuditKind.STATUS_CHANGED), any(), any(), any()); + } + + @Test + void updateDocument_logsStatusChanged_whenFileCausesPlaceholderToUploadedTransition() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> { + Document saved = inv.getArgument(0); + saved.setStatus(DocumentStatus.UPLOADED); + return saved; + }); + MockMultipartFile file = new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[]{1}); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash")); + + documentService.updateDocument(id, new DocumentUpdateDTO(), file); + + verify(auditService).log(eq(AuditKind.STATUS_CHANGED), isNull(), eq(id), any()); + verify(auditService, never()).log(eq(AuditKind.METADATA_UPDATED), any(), any(), any()); + } + + @Test + void storeDocument_logsFileUploaded_whenExistingDocumentWasPlaceholder() throws Exception { + String filename = "test.pdf"; + Document existing = Document.builder() + .id(UUID.randomUUID()).originalFilename(filename).status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.of(existing)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash")); + when(documentRepository.save(any())).thenReturn(existing); + + MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1}); + documentService.storeDocument(file); + + verify(auditService).log(eq(AuditKind.FILE_UPLOADED), isNull(), eq(existing.getId()), isNull()); + } + + @Test + void storeDocument_doesNotLogFileUploaded_whenDocumentIsAlreadyUploaded() throws Exception { + String filename = "test.pdf"; + Document existing = Document.builder() + .id(UUID.randomUUID()).originalFilename(filename).status(DocumentStatus.UPLOADED).build(); + when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.of(existing)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash")); + when(documentRepository.save(any())).thenReturn(existing); + + MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1}); + documentService.storeDocument(file); + + verify(auditService, never()).log(any(), any(), any(), any()); + } + + @Test + void attachFile_logsFileUploaded_whenDocumentWasPlaceholder() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash")); + when(documentRepository.save(any())).thenAnswer(inv -> { + Document saved = inv.getArgument(0); + saved.setStatus(DocumentStatus.UPLOADED); + return saved; + }); + + MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); + documentService.attachFile(id, file); + + verify(auditService).log(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull()); + } }