feat(backend): dispatch thumbnail generation from DocumentService upload paths

All four upload code paths (storeDocument, createDocument, updateDocument,
attachFile) now call thumbnailAsyncRunner.dispatchAfterCommit(id) after
the document save. createDocument and updateDocument only dispatch when a
file was actually provided/replaced.

The dispatch is afterCommit-safe: if the surrounding @Transactional
method rolls back, no thumbnail is generated for a document that never
reached the DB.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 21:57:36 +02:00
parent 3b7ef6117e
commit 7d0e13c591
2 changed files with 116 additions and 2 deletions

View File

@@ -64,6 +64,7 @@ public class DocumentService {
private final AuditService auditService;
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AuditLogQueryService auditLogQueryService;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
public record StoreResult(Document document, boolean isNew) {}
@@ -125,6 +126,7 @@ public class DocumentService {
if (wasPlaceholder) {
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
}
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
return new StoreResult(saved, isNew);
}
@@ -187,7 +189,8 @@ public class DocumentService {
}
// Datei
if (file != null && !file.isEmpty()) {
boolean fileUploaded = file != null && !file.isEmpty();
if (fileUploaded) {
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
@@ -197,6 +200,9 @@ public class DocumentService {
Document finalDoc = documentRepository.save(doc);
documentVersionService.recordVersion(finalDoc);
if (fileUploaded) {
thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId());
}
return finalDoc;
}
@@ -249,7 +255,8 @@ public class DocumentService {
}
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) {
boolean fileReplaced = newFile != null && !newFile.isEmpty();
if (fileReplaced) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
@@ -268,6 +275,10 @@ public class DocumentService {
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
}
if (fileReplaced) {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
return saved;
}
@@ -329,6 +340,7 @@ public class DocumentService {
}
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
if (wasPlaceholder) {
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
}

View File

@@ -55,6 +55,7 @@ class DocumentServiceTest {
@Mock AuditService auditService;
@Mock AuditLogQueryService auditLogQueryService;
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
@InjectMocks DocumentService documentService;
// ─── deleteDocument ───────────────────────────────────────────────────────
@@ -257,6 +258,107 @@ class DocumentServiceTest {
verify(documentVersionService).recordVersion(any(Document.class));
}
// ─── thumbnail dispatch ───────────────────────────────────────────────────
@Test
void storeDocument_dispatchesThumbnailAfterSave() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
UUID savedId = UUID.randomUUID();
Document saved = Document.builder().id(savedId).originalFilename("new.pdf").build();
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenReturn(saved);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
documentService.storeDocument(file, null);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
}
@Test
void createDocument_dispatchesThumbnail_onlyWhenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("No file");
UUID savedId = UUID.randomUUID();
Document saved = Document.builder().id(savedId).title("No file")
.originalFilename("No file").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
documentService.createDocument(dto, null);
verifyNoInteractions(thumbnailAsyncRunner);
}
@Test
void createDocument_dispatchesThumbnail_whenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("With file");
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
UUID savedId = UUID.randomUUID();
Document saved = Document.builder().id(savedId).title("With file")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any()))
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
documentService.createDocument(dto, file);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
}
@Test
void updateDocument_dispatchesThumbnail_onlyWhenFileReplaced() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Doc").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);
verifyNoInteractions(thumbnailAsyncRunner);
}
@Test
void updateDocument_dispatchesThumbnail_whenNewFileProvided() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Doc").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
org.springframework.mock.web.MockMultipartFile newFile =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any()))
.thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
}
@Test
void attachFile_dispatchesThumbnailAfterSave() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Placeholder").originalFilename("placeholder")
.status(DocumentStatus.PLACEHOLDER).build();
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any()))
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
documentService.attachFile(id, file, null);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
}
// ─── storeDocument ───────────────────────────────────────────────────────
@Test