From 6cf060159092dc0aebae9941736e526673f8c099 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:34:03 +0200 Subject: [PATCH 01/24] feat(db): add thumbnail_key and thumbnail_generated_at to documents Adds two nullable columns to the documents table and their JPA mappings on the Document entity. Both are left out of the OpenAPI required-mode schema so the generated TypeScript type exposes them as optional. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../java/org/raddatz/familienarchiv/model/Document.java | 7 +++++++ .../db/migration/V52__add_document_thumbnails.sql | 3 +++ 2 files changed, 10 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V52__add_document_thumbnails.sql diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 5a5ca54a..d2cea5da 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -43,6 +43,13 @@ public class Document { @Column(name = "file_hash", length = 64) private String fileHash; + // S3 key of the generated thumbnail (e.g. "thumbnails/{docId}.jpg"); null until generated + @Column(name = "thumbnail_key") + private String thumbnailKey; + + @Column(name = "thumbnail_generated_at") + private LocalDateTime thumbnailGeneratedAt; + // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf") @Column(name = "original_filename", nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/src/main/resources/db/migration/V52__add_document_thumbnails.sql b/backend/src/main/resources/db/migration/V52__add_document_thumbnails.sql new file mode 100644 index 00000000..f848a49c --- /dev/null +++ b/backend/src/main/resources/db/migration/V52__add_document_thumbnails.sql @@ -0,0 +1,3 @@ +ALTER TABLE documents + ADD COLUMN thumbnail_key VARCHAR(255), + ADD COLUMN thumbnail_generated_at TIMESTAMP; -- 2.49.1 From b8962f4337edc3f32363f62309f757819b34db22 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:35:08 +0200 Subject: [PATCH 02/24] feat(backend): add DocumentRepository finder for thumbnail backfill Adds findByFilePathIsNotNullAndThumbnailKeyIsNull() used by the upcoming ThumbnailBackfillService to locate documents that have a file attached but no thumbnail yet. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../raddatz/familienarchiv/repository/DocumentRepository.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index 92517f4b..1f1af2cc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -46,6 +46,8 @@ public interface DocumentRepository extends JpaRepository, JpaSp List findByFileHashIsNullAndFilePathIsNotNull(); + List findByFilePathIsNotNullAndThumbnailKeyIsNull(); + @Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids") List findIdAndTitleByIdIn(@Param("ids") Collection ids); -- 2.49.1 From a2333975f98a7c588d71beac12d597ba4803b60b Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:36:06 +0200 Subject: [PATCH 03/24] feat(backend): add THUMBNAIL_BACKFILL_ALREADY_RUNNING error code Mirrors the IMPORT_ALREADY_RUNNING pattern for the concurrent-start guard in ThumbnailBackfillService. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../java/org/raddatz/familienarchiv/exception/ErrorCode.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 0fd93860..85cd7d2c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -38,6 +38,10 @@ public enum ErrorCode { /** A mass import is already in progress; only one can run at a time. 409 */ IMPORT_ALREADY_RUNNING, + // --- Thumbnails --- + /** A thumbnail backfill is already in progress; only one can run at a time. 409 */ + THUMBNAIL_BACKFILL_ALREADY_RUNNING, + // --- Invites --- /** The invite code does not exist. 404 */ INVITE_NOT_FOUND, -- 2.49.1 From 2aa3b955f9b4f69ed7e791a77cec0229527a5b97 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:37:05 +0200 Subject: [PATCH 04/24] build: add twelvemonkeys-imageio-tiff for thumbnail TIFF support JDK ImageIO handles JPEG, PNG, BMP, GIF out of the box but not TIFF. Since the document upload allowlist permits image/tiff, the thumbnail generator must also decode it. Refs #307 Co-Authored-By: Claude Opus 4.7 --- backend/pom.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/pom.xml b/backend/pom.xml index 33c1483e..5a733e76 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -164,12 +164,19 @@ 3.0.2 - + org.apache.pdfbox pdfbox 3.0.4 + + + + com.twelvemonkeys.imageio + imageio-tiff + 3.12.0 + -- 2.49.1 From 07019f54e8a1488abf00133d56d7ac866821e8b8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:41:15 +0200 Subject: [PATCH 05/24] feat(backend): add FileService.downloadFileStream for memory-efficient reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thumbnail generation will call this for PDFs up to 50 MB — loading the full byte[] via downloadFileBytes would cause real memory pressure on the single-VPS deploy. Stream-based reads let PDFBox parse the first page without holding the whole file in heap. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/service/FileService.java | 21 +++++++++++ .../service/FileServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java index 29ca2be6..3208455f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java @@ -112,6 +112,27 @@ public class FileService { } } + /** + * Opens a streaming download from S3/MinIO. The caller is responsible for + * closing the returned stream — typically via try-with-resources. Preferred + * over {@link #downloadFileBytes(String)} for large files (multi-MB PDFs + * during thumbnail generation) because it avoids loading the entire file + * into heap memory. + */ + public InputStream downloadFileStream(String s3Key) throws IOException { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + return s3Client.getObject(getObjectRequest); + } catch (NoSuchKeyException e) { + throw new StorageFileNotFoundException("File not found in storage: " + s3Key); + } catch (S3Exception e) { + throw new IOException("Failed to open stream from storage: " + e.getMessage(), e); + } + } + /** * Generates a presigned URL for downloading an object from S3/MinIO. * Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java index e043c3b7..3fd79033 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java @@ -197,4 +197,39 @@ class FileServiceTest { .isInstanceOf(IOException.class) .hasMessageContaining("Failed to download"); } + + // ─── downloadFileStream ──────────────────────────────────────────────────── + + @Test + void downloadFileStream_returnsStreamableContent() throws IOException { + byte[] content = "streamed bytes".getBytes(); + GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build(); + ResponseInputStream stream = new ResponseInputStream<>( + response, AbortableInputStream.create(new ByteArrayInputStream(content))); + when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream); + + try (java.io.InputStream result = fileService.downloadFileStream("documents/file.pdf")) { + assertThat(result.readAllBytes()).isEqualTo(content); + } + } + + @Test + void downloadFileStream_throwsStorageFileNotFoundException_whenNoSuchKey() { + NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build(); + when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex); + + assertThatThrownBy(() -> fileService.downloadFileStream("missing/key.pdf")) + .isInstanceOf(FileService.StorageFileNotFoundException.class) + .hasMessageContaining("missing/key.pdf"); + } + + @Test + void downloadFileStream_throwsIOException_whenS3Exception() { + S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build(); + when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex); + + assertThatThrownBy(() -> fileService.downloadFileStream("documents/file.pdf")) + .isInstanceOf(IOException.class) + .hasMessageContaining("Failed to open stream"); + } } -- 2.49.1 From 0bb18c6789906d8c5daa91ae19a161d1f17c247d Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:42:05 +0200 Subject: [PATCH 06/24] feat(backend): add thumbnailExecutor bean for isolated thumbnail workload Dedicated thread pool (core=1, max=2, queue=200) with CallerRunsPolicy for back-pressure. Keeps thumbnail rendering off the shared taskExecutor used by OCR and out of the AbortPolicy queue that drops work on overflow. Quick-upload batches (15+ files) now apply back-pressure instead of silently dropping thumbnail jobs. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/config/AsyncConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java index 9a7caa80..bafddbfc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java @@ -37,4 +37,19 @@ public class AsyncConfig { executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); return executor; } + + @Bean("thumbnailExecutor") + public Executor thumbnailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(200); + executor.setThreadNamePrefix("Thumbnail-"); + // CallerRunsPolicy applies back-pressure to quick-upload batches and admin backfill + // instead of dropping work (shared taskExecutor uses AbortPolicy). Safe because the + // task is dispatched via TransactionSynchronization.afterCommit, which runs on a + // post-commit callback thread without active transaction synchronization. + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + return executor; + } } \ No newline at end of file -- 2.49.1 From 955c497ba0f43fb960956e5e594054ab4e19a783 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:46:08 +0200 Subject: [PATCH 07/24] feat(backend): add ThumbnailService for PDF and image thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a 240px-wide JPEG (quality 85) from either a PDF first page via PDFBox or a JPEG/PNG/TIFF scan via ImageIO, then uploads to S3 under thumbnails/{docId}.jpg and updates the Document entity. Scaling uses Graphics2D.drawImage with VALUE_INTERPOLATION_BILINEAR (not deprecated Image.getScaledInstance). Source is streamed via FileService.downloadFileStream to avoid buffering 50MB PDFs. Never throws — returns Outcome.SKIPPED for unsupported content types and Outcome.FAILED for rendering/upload errors so the backfill can tally them without aborting the run. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailService.java | 159 ++++++++++++ .../service/ThumbnailServiceTest.java | 232 ++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java new file mode 100644 index 00000000..465ff20d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -0,0 +1,159 @@ +package org.raddatz.familienarchiv.service; + +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.io.RandomAccessReadBuffer; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.Set; + +/** + * Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image) + * and uploads them to the S3 thumbnails/ prefix. Fire-and-forget from upload paths via + * {@link ThumbnailAsyncRunner}; also invoked by {@link ThumbnailBackfillService} for + * historical documents. Explicitly does not throw — failures are returned as + * {@link Outcome#FAILED} so the backfill can account for them without aborting the run. + */ +@Service +@Slf4j +public class ThumbnailService { + + public enum Outcome { SUCCESS, SKIPPED, FAILED } + + private static final int THUMBNAIL_WIDTH = 240; + private static final float JPEG_QUALITY = 0.85f; + private static final int PDF_RENDER_DPI = 100; + private static final String PDF_CONTENT_TYPE = "application/pdf"; + private static final Set IMAGE_CONTENT_TYPES = + Set.of("image/jpeg", "image/png", "image/tiff"); + + private final FileService fileService; + private final S3Client s3Client; + private final DocumentRepository documentRepository; + + @Value("${app.s3.bucket}") + private String bucketName; + + public ThumbnailService(FileService fileService, S3Client s3Client, + DocumentRepository documentRepository) { + this.fileService = fileService; + this.s3Client = s3Client; + this.documentRepository = documentRepository; + } + + public Outcome generate(Document doc) { + if (doc.getFilePath() == null) { + log.debug("Document {} has no filePath, skipping thumbnail", doc.getId()); + return Outcome.SKIPPED; + } + String contentType = doc.getContentType(); + if (contentType == null || !isSupported(contentType)) { + log.warn("Document {} has unsupported contentType {}, skipping thumbnail", + doc.getId(), contentType); + return Outcome.SKIPPED; + } + + BufferedImage source; + try { + source = PDF_CONTENT_TYPE.equals(contentType) + ? renderPdfFirstPage(doc.getFilePath()) + : readImage(doc.getFilePath()); + } catch (Exception e) { + log.warn("Thumbnail generation failed for doc={} reason={}", + doc.getId(), e.getMessage()); + return Outcome.FAILED; + } + + try { + BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH); + byte[] jpeg = encodeJpeg(scaled, JPEG_QUALITY); + String thumbnailKey = "thumbnails/" + doc.getId() + ".jpg"; + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(thumbnailKey) + .contentType("image/jpeg") + .build(), + RequestBody.fromBytes(jpeg)); + + doc.setThumbnailKey(thumbnailKey); + doc.setThumbnailGeneratedAt(LocalDateTime.now()); + documentRepository.save(doc); + return Outcome.SUCCESS; + } catch (Exception e) { + log.warn("Thumbnail generation failed for doc={} reason={}", + doc.getId(), e.getMessage()); + return Outcome.FAILED; + } + } + + private boolean isSupported(String contentType) { + return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType); + } + + private BufferedImage renderPdfFirstPage(String s3Key) throws IOException { + try (InputStream in = fileService.downloadFileStream(s3Key); + PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) { + PDFRenderer renderer = new PDFRenderer(pdf); + return renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB); + } + } + + private BufferedImage readImage(String s3Key) throws IOException { + try (InputStream in = fileService.downloadFileStream(s3Key)) { + BufferedImage img = ImageIO.read(in); + if (img == null) { + throw new IOException("No ImageIO reader available for " + s3Key); + } + return img; + } + } + + private BufferedImage scaleToWidth(BufferedImage source, int targetWidth) { + int sourceWidth = source.getWidth(); + int sourceHeight = source.getHeight(); + int targetHeight = Math.max(1, Math.round((float) targetWidth * sourceHeight / sourceWidth)); + BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(source, 0, 0, targetWidth, targetHeight, null); + g.dispose(); + return scaled; + } + + private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next(); + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(quality); + try (ImageOutputStream out = ImageIO.createImageOutputStream(bos)) { + writer.setOutput(out); + writer.write(null, new IIOImage(image, null, null), param); + } finally { + writer.dispose(); + } + return bos.toByteArray(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java new file mode 100644 index 00000000..bf6827af --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -0,0 +1,232 @@ +package org.raddatz.familienarchiv.service; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class ThumbnailServiceTest { + + private FileService fileService; + private S3Client s3Client; + private DocumentRepository documentRepository; + private ThumbnailService thumbnailService; + + @BeforeEach + void setUp() { + fileService = mock(FileService.class); + s3Client = mock(S3Client.class); + documentRepository = mock(DocumentRepository.class); + thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository); + ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket"); + when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0)); + } + + @Test + void generate_returnsSkipped_whenDocumentHasNoFilePath() { + Document doc = makeDoc("application/pdf", null); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED); + verifyNoInteractions(s3Client); + assertThat(doc.getThumbnailKey()).isNull(); + } + + @Test + void generate_returnsSkipped_forUnsupportedContentType() throws IOException { + Document doc = makeDoc("application/msword", "documents/letter.doc"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3})); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED); + verifyNoInteractions(s3Client); + assertThat(doc.getThumbnailKey()).isNull(); + } + + @Test + void generate_rendersPdf_uploadsJpeg_updatesEntity() throws IOException { + Document doc = makeDoc("application/pdf", "documents/letter.pdf"); + byte[] pdfBytes = createSamplePdf(); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(pdfBytes)); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + + ArgumentCaptor putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(putCaptor.capture(), bodyCaptor.capture()); + + PutObjectRequest req = putCaptor.getValue(); + assertThat(req.bucket()).isEqualTo("test-bucket"); + assertThat(req.key()).isEqualTo("thumbnails/" + doc.getId() + ".jpg"); + assertThat(req.contentType()).isEqualTo("image/jpeg"); + + byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream()); + BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded)); + assertThat(jpg).isNotNull(); + assertThat(jpg.getWidth()).isEqualTo(240); + + assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg"); + assertThat(doc.getThumbnailGeneratedAt()).isNotNull(); + verify(documentRepository).save(doc); + } + + @Test + void generate_rendersPng_uploadsJpegAtWidth240() throws IOException { + Document doc = makeDoc("image/png", "documents/scan.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture()); + byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream()); + BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded)); + assertThat(jpg.getWidth()).isEqualTo(240); + assertThat(jpg.getHeight()).isEqualTo(320); // 600x800 -> 240x320 + } + + @Test + void generate_rendersJpeg_uploadsScaledJpeg() throws IOException { + Document doc = makeDoc("image/jpeg", "documents/photo.jpg"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture()); + BufferedImage jpg = ImageIO.read(new ByteArrayInputStream( + readAll(bodyCaptor.getValue().contentStreamProvider().newStream()))); + assertThat(jpg.getWidth()).isEqualTo(240); + assertThat(jpg.getHeight()).isEqualTo(120); // 800x400 -> 240x120 + } + + @Test + void generate_returnsFailed_whenS3PutThrows() throws IOException { + Document doc = makeDoc("application/pdf", "documents/letter.pdf"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePdf())); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow((S3Exception) S3Exception.builder().message("quota exceeded").statusCode(507).build()); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + assertThat(doc.getThumbnailKey()).isNull(); + verify(documentRepository, never()).save(any()); + } + + @Test + void generate_returnsFailed_whenSourceStreamThrows() throws IOException { + Document doc = makeDoc("application/pdf", "documents/letter.pdf"); + when(fileService.downloadFileStream(anyString())) + .thenThrow(new IOException("network blip")); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + verifyNoInteractions(s3Client); + verify(documentRepository, never()).save(any()); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private Document makeDoc(String contentType, String filePath) { + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Test Doc") + .originalFilename("test.pdf") + .status(DocumentStatus.UPLOADED) + .contentType(contentType) + .filePath(filePath) + .build(); + doc.setCreatedAt(LocalDateTime.now()); + doc.setUpdatedAt(LocalDateTime.now()); + return doc; + } + + private static byte[] createSamplePdf() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream content = new PDPageContentStream(doc, page)) { + content.beginText(); + content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24); + content.newLineAtOffset(100, 700); + content.showText("Lieber Hans,"); + content.endText(); + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + doc.save(bos); + return bos.toByteArray(); + } + } + + private static byte[] createSamplePng(int width, int height) throws IOException { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.LIGHT_GRAY); + g.fillRect(0, 0, width, height); + g.setColor(Color.DARK_GRAY); + g.fillRect(0, 0, width, height / 4); + g.dispose(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", bos); + return bos.toByteArray(); + } + + private static byte[] createSampleJpeg(int width, int height) throws IOException { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, width, height); + g.dispose(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "jpg", bos); + return bos.toByteArray(); + } + + private static byte[] readAll(InputStream stream) throws IOException { + try (stream) { + return stream.readAllBytes(); + } + } +} -- 2.49.1 From 3b7ef6117e7781de0b48e5b10a74ead8180a46a9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:49:26 +0200 Subject: [PATCH 08/24] feat(backend): add ThumbnailAsyncRunner with afterCommit dispatch and timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges @Transactional upload paths to the async thumbnail pipeline. dispatchAfterCommit registers a TransactionSynchronization so the async task only fires after the surrounding commit (and is silently skipped on rollback) — mirrors the AuditService.logAfterCommit pattern. generateAsync wraps the full ThumbnailService.generate call in a 30s watchdog so a hung PDFBox render cannot occupy a thumbnailExecutor slot indefinitely. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailAsyncRunner.java | 92 ++++++++++++++ .../service/ThumbnailAsyncRunnerTest.java | 118 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java new file mode 100644 index 00000000..a90a7a39 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java @@ -0,0 +1,92 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Bridges document upload paths to asynchronous thumbnail generation. Use + * {@link #dispatchAfterCommit(UUID)} from inside {@code @Transactional} service methods — + * it registers a post-commit hook so the async task only fires when the surrounding + * transaction actually commits, and is silently skipped on rollback. Mirrors + * {@link org.raddatz.familienarchiv.audit.AuditService#logAfterCommit}. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ThumbnailAsyncRunner { + + private final DocumentRepository documentRepository; + private final ThumbnailService thumbnailService; + + /** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */ + private long generateTimeoutSeconds = 30L; + + /** + * Registers a post-commit hook that triggers asynchronous thumbnail generation for the + * given document. When no transaction is active the task is dispatched immediately. + * Safe to call from inside {@code @Transactional} service methods. + */ + public void dispatchAfterCommit(UUID documentId) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + generateAsync(documentId); + } + }); + } else { + generateAsync(documentId); + } + } + + /** + * Runs thumbnail generation on the {@code thumbnailExecutor} pool, wrapped in a watchdog + * timeout so a hung PDFBox render cannot occupy a pool thread indefinitely. Never throws: + * all errors and timeouts are logged and swallowed so upload paths are not affected. + */ + @Async("thumbnailExecutor") + public void generateAsync(UUID documentId) { + Optional docOpt = documentRepository.findById(documentId); + if (docOpt.isEmpty()) { + log.warn("Thumbnail generation skipped: document not found id={}", documentId); + return; + } + Document doc = docOpt.get(); + + ExecutorService timeoutWorker = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "Thumbnail-Render-" + documentId); + t.setDaemon(true); + return t; + }); + try { + Future future = timeoutWorker.submit( + () -> thumbnailService.generate(doc)); + try { + future.get(generateTimeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + log.warn("Thumbnail generation timed out after {}s for doc={}", + generateTimeoutSeconds, documentId); + } catch (Exception e) { + log.warn("Thumbnail generation errored for doc={} reason={}", + documentId, e.getMessage()); + } + } finally { + timeoutWorker.shutdownNow(); + } + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java new file mode 100644 index 00000000..2f55b889 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java @@ -0,0 +1,118 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ThumbnailAsyncRunnerTest { + + private DocumentRepository documentRepository; + private ThumbnailService thumbnailService; + private ThumbnailAsyncRunner runner; + + @BeforeEach + void setUp() { + documentRepository = mock(DocumentRepository.class); + thumbnailService = mock(ThumbnailService.class); + runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService); + } + + @Test + void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + runner.dispatchAfterCommit(id); + + verify(thumbnailService).generate(doc); + } + + @Test + void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + TransactionSynchronizationManager.initSynchronization(); + try { + runner.dispatchAfterCommit(id); + + // Nothing fired yet — registered, not executed + verify(thumbnailService, never()).generate(any()); + + // Simulate commit + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionSynchronization.class); + assertThat(TransactionSynchronizationManager.getSynchronizations()).hasSize(1); + TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit(); + + verify(thumbnailService).generate(doc); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void dispatchAfterCommit_whenRollback_doesNotDispatch() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + TransactionSynchronizationManager.initSynchronization(); + try { + runner.dispatchAfterCommit(id); + + // Simulate rollback — afterCompletion with STATUS_ROLLED_BACK, no afterCommit fired + TransactionSynchronizationManager.getSynchronizations().get(0) + .afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); + + verify(thumbnailService, never()).generate(any()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void generateAsync_skipsWhenDocumentMissing() { + UUID id = UUID.randomUUID(); + when(documentRepository.findById(id)).thenReturn(Optional.empty()); + + runner.generateAsync(id); + + verifyNoInteractions(thumbnailService); + } + + @Test + void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + // generate sleeps longer than the timeout — simulates a hung PDFBox render + when(thumbnailService.generate(doc)).thenAnswer(inv -> { + Thread.sleep(5_000); + return ThumbnailService.Outcome.SUCCESS; + }); + // Shrink timeout for the test + ReflectionTestUtils.setField(runner, "generateTimeoutSeconds", 1L); + + long start = System.currentTimeMillis(); + runner.generateAsync(id); + long elapsed = System.currentTimeMillis() - start; + + // Must return before the 5s sleep — within ~2s with timeout=1s plus overhead + assertThat(elapsed).isLessThan(3_000); + } +} -- 2.49.1 From 7d0e13c591c12745842644b11580292f975ff12f Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:57:36 +0200 Subject: [PATCH 09/24] 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 --- .../service/DocumentService.java | 16 ++- .../service/DocumentServiceTest.java | 102 ++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) 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 fc26dcc3..5b563e47 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -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); } 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 7eefde78..657c822d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -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 -- 2.49.1 From 0344a0c7ff79917d35cb247da42aa86dc7b502ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:58:24 +0200 Subject: [PATCH 10/24] feat(backend): dispatch thumbnail generation from MassImportService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ODS/Excel imports that actually upload a file (file.isPresent()) now trigger thumbnail generation alongside hash/metadata. Metadata-only import rows produce no thumbnail — nothing to render. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../raddatz/familienarchiv/service/MassImportService.java | 6 +++++- .../familienarchiv/service/MassImportServiceTest.java | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java index 5f3cb792..7e847ff8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java @@ -59,6 +59,7 @@ public class MassImportService { private final PersonService personService; private final TagService tagService; private final S3Client s3Client; + private final ThumbnailAsyncRunner thumbnailAsyncRunner; @Value("${app.s3.bucket}") private String bucketName; @@ -332,7 +333,10 @@ public class MassImportService { if (tag != null) doc.getTags().add(tag); doc.setMetadataComplete(metadataComplete); - documentRepository.save(doc); + Document saved = documentRepository.save(doc); + if (file.isPresent()) { + thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); + } log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/MassImportServiceTest.java index f20aec35..62b138b7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/MassImportServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/MassImportServiceTest.java @@ -39,12 +39,13 @@ class MassImportServiceTest { @Mock PersonService personService; @Mock TagService tagService; @Mock S3Client s3Client; + @Mock ThumbnailAsyncRunner thumbnailAsyncRunner; MassImportService service; @BeforeEach void setUp() { - service = new MassImportService(documentRepository, personService, tagService, s3Client); + service = new MassImportService(documentRepository, personService, tagService, s3Client, thumbnailAsyncRunner); ReflectionTestUtils.setField(service, "bucketName", "test-bucket"); ReflectionTestUtils.setField(service, "colIndex", 0); ReflectionTestUtils.setField(service, "colBox", 1); -- 2.49.1 From 09fc871756853302b55b8f91f9a7c91c42495648 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:02:20 +0200 Subject: [PATCH 11/24] feat(backend): add ThumbnailBackfillService for regenerating missing thumbnails Sequentially processes all documents with a file but no thumbnail and tallies processed / skipped / failed counts. Runs on thumbnailExecutor so it shares back-pressure with live upload thumbnails but can never saturate them (single-threaded loop). Concurrent start rejected with THUMBNAIL_BACKFILL_ALREADY_RUNNING. Emits a structured summary log line on completion for operator visibility. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailBackfillService.java | 104 ++++++++++++ .../service/ThumbnailBackfillServiceTest.java | 154 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java new file mode 100644 index 00000000..60855ba6 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java @@ -0,0 +1,104 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Sequentially regenerates thumbnails for documents that have a file attached but no + * thumbnail yet. Runs on the {@code thumbnailExecutor} pool — single-threaded iteration + * is intentional: PDFBox + ImageIO are memory-heavy and we cap peak usage by processing + * documents one at a time. Only one backfill can run at a time; concurrent starts are + * rejected with {@link ErrorCode#THUMBNAIL_BACKFILL_ALREADY_RUNNING}. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ThumbnailBackfillService { + + public enum State { IDLE, RUNNING, DONE, FAILED } + + public record BackfillStatus( + State state, + String message, + int total, + int processed, + int skipped, + int failed, + LocalDateTime startedAt + ) {} + + private final DocumentRepository documentRepository; + private final ThumbnailService thumbnailService; + + private volatile BackfillStatus currentStatus = new BackfillStatus( + State.IDLE, "Kein Backfill gestartet.", 0, 0, 0, 0, null); + + public BackfillStatus getStatus() { + return currentStatus; + } + + @Async("thumbnailExecutor") + public void runBackfillAsync() { + if (currentStatus.state() == State.RUNNING) { + throw DomainException.conflict(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING, + "Thumbnail-Backfill läuft bereits"); + } + + LocalDateTime startedAt = LocalDateTime.now(); + List docs; + try { + docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull(); + } catch (Exception e) { + currentStatus = new BackfillStatus(State.FAILED, + "Backfill fehlgeschlagen: " + e.getMessage(), + 0, 0, 0, 0, startedAt); + log.warn("Thumbnail backfill aborted before starting: {}", e.getMessage()); + return; + } + + int total = docs.size(); + currentStatus = new BackfillStatus(State.RUNNING, + "Backfill läuft…", total, 0, 0, 0, startedAt); + log.info("Thumbnail backfill started: total={}", total); + + int processed = 0; + int skipped = 0; + int failed = 0; + for (Document doc : docs) { + ThumbnailService.Outcome outcome; + try { + outcome = thumbnailService.generate(doc); + } catch (Exception e) { + log.warn("Thumbnail generation failed for doc={} reason={}", + doc.getId(), e.getMessage()); + outcome = ThumbnailService.Outcome.FAILED; + } + switch (outcome) { + case SUCCESS -> processed++; + case SKIPPED -> skipped++; + case FAILED -> failed++; + } + currentStatus = new BackfillStatus(State.RUNNING, + "Backfill läuft…", total, processed, skipped, failed, startedAt); + } + + long durationMs = Duration.between(startedAt, LocalDateTime.now()).toMillis(); + log.info("Thumbnail backfill complete: total={} processed={} skipped={} failed={} durationMs={}", + total, processed, skipped, failed, durationMs); + + currentStatus = new BackfillStatus(State.DONE, + String.format("Fertig: %d erzeugt, %d übersprungen, %d fehlgeschlagen.", + processed, skipped, failed), + total, processed, skipped, failed, startedAt); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java new file mode 100644 index 00000000..4e076395 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java @@ -0,0 +1,154 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +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.Mockito.*; + +class ThumbnailBackfillServiceTest { + + private DocumentRepository documentRepository; + private ThumbnailService thumbnailService; + private ThumbnailBackfillService backfillService; + + @BeforeEach + void setUp() { + documentRepository = mock(DocumentRepository.class); + thumbnailService = mock(ThumbnailService.class); + backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService); + } + + @Test + void initialStatus_isIdle() { + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.IDLE); + assertThat(status.total()).isZero(); + assertThat(status.processed()).isZero(); + assertThat(status.startedAt()).isNull(); + } + + @Test + void runBackfillAsync_processesAllDocumentsAndFinishesDone() { + Document a = doc(); + Document b = doc(); + Document c = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b, c)); + when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.total()).isEqualTo(3); + assertThat(status.processed()).isEqualTo(3); + assertThat(status.skipped()).isZero(); + assertThat(status.failed()).isZero(); + verify(thumbnailService, times(3)).generate(any()); + } + + @Test + void runBackfillAsync_countsSkippedSeparately() { + Document a = doc(); + Document b = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b)); + when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); + when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.processed()).isEqualTo(1); + assertThat(status.skipped()).isEqualTo(1); + assertThat(status.failed()).isZero(); + } + + @Test + void runBackfillAsync_continuesAfterFailureAndCountsIt() { + Document a = doc(); + Document b = doc(); + Document c = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b, c)); + when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); + when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED); + when(thumbnailService.generate(c)).thenReturn(ThumbnailService.Outcome.SUCCESS); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.processed()).isEqualTo(2); + assertThat(status.failed()).isEqualTo(1); + verify(thumbnailService, times(3)).generate(any()); + } + + @Test + void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() { + Document a = doc(); + Document b = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b)); + when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom")); + when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.processed()).isEqualTo(1); + assertThat(status.failed()).isEqualTo(1); + } + + @Test + void runBackfillAsync_rejectsConcurrentStart() { + // Force state=RUNNING via reflection + ThumbnailBackfillService.BackfillStatus running = new ThumbnailBackfillService.BackfillStatus( + ThumbnailBackfillService.State.RUNNING, "running", 10, 5, 0, 0, LocalDateTime.now()); + ReflectionTestUtils.setField(backfillService, "currentStatus", running); + + assertThatThrownBy(() -> backfillService.runBackfillAsync()) + .isInstanceOf(DomainException.class) + .satisfies(ex -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING)); + } + + @Test + void runBackfillAsync_setsStartedAtAndMessage() { + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(doc())); + when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); + + LocalDateTime before = LocalDateTime.now().minusSeconds(1); + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.startedAt()).isAfter(before); + assertThat(status.message()).isNotBlank(); + } + + private Document doc() { + return Document.builder() + .id(UUID.randomUUID()) + .title("t") + .originalFilename("f.pdf") + .filePath("documents/f.pdf") + .contentType("application/pdf") + .build(); + } +} -- 2.49.1 From 323ec1ec547f6c513ed04b4229b5399d2469566f Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:05:47 +0200 Subject: [PATCH 12/24] feat(backend): add AdminController endpoints for thumbnail backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/admin/generate-thumbnails → triggers async backfill, 202 - GET /api/admin/thumbnail-status → returns current BackfillStatus Both gated by the class-level @RequirePermission(Permission.ADMIN). Shape and polling semantics mirror the mass-import endpoints. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../controller/AdminController.java | 13 +++++ .../controller/AdminControllerTest.java | 58 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java index 918697cc..4311b87a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java @@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.MassImportService; +import org.raddatz.familienarchiv.service.ThumbnailBackfillService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -23,6 +24,7 @@ public class AdminController { private final MassImportService massImportService; private final DocumentService documentService; private final DocumentVersionService documentVersionService; + private final ThumbnailBackfillService thumbnailBackfillService; @PostMapping("/trigger-import") public ResponseEntity triggerMassImport() { @@ -47,4 +49,15 @@ public class AdminController { int count = documentService.backfillFileHashes(); return ResponseEntity.ok(new BackfillResult(count)); } + + @PostMapping("/generate-thumbnails") + public ResponseEntity generateThumbnails() { + thumbnailBackfillService.runBackfillAsync(); + return ResponseEntity.accepted().body(thumbnailBackfillService.getStatus()); + } + + @GetMapping("/thumbnail-status") + public ResponseEntity thumbnailStatus() { + return ResponseEntity.ok(thumbnailBackfillService.getStatus()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java index a456183b..6a68083b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java @@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.MassImportService; +import org.raddatz.familienarchiv.service.ThumbnailBackfillService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -16,10 +17,13 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.time.LocalDateTime; import java.util.List; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -33,6 +37,7 @@ class AdminControllerTest { @MockitoBean MassImportService massImportService; @MockitoBean DocumentService documentService; @MockitoBean DocumentVersionService documentVersionService; + @MockitoBean ThumbnailBackfillService thumbnailBackfillService; @MockitoBean CustomUserDetailsService customUserDetailsService; @Test @@ -83,4 +88,57 @@ class AdminControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.count").value(3)); } + + // ─── POST /api/admin/generate-thumbnails ─────────────────────────────────── + + @Test + void generateThumbnails_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/admin/generate-thumbnails")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(roles = "USER") + void generateThumbnails_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(post("/api/admin/generate-thumbnails")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void generateThumbnails_returns202_withStatus_whenAdmin() throws Exception { + ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus( + ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now()); + when(thumbnailBackfillService.getStatus()).thenReturn(status); + + mockMvc.perform(post("/api/admin/generate-thumbnails")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.state").value("RUNNING")) + .andExpect(jsonPath("$.total").value(10)); + + verify(thumbnailBackfillService).runBackfillAsync(); + } + + // ─── GET /api/admin/thumbnail-status ─────────────────────────────────────── + + @Test + void thumbnailStatus_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/admin/thumbnail-status")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void thumbnailStatus_returns200_withCurrentStatus_whenAdmin() throws Exception { + ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus( + ThumbnailBackfillService.State.DONE, "Fertig: 5 erzeugt, 0 übersprungen, 0 fehlgeschlagen.", + 5, 5, 0, 0, LocalDateTime.now()); + when(thumbnailBackfillService.getStatus()).thenReturn(status); + + mockMvc.perform(get("/api/admin/thumbnail-status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("DONE")) + .andExpect(jsonPath("$.processed").value(5)) + .andExpect(jsonPath("$.total").value(5)); + } } -- 2.49.1 From f11a29504a4a69de3db995d0fccaca850da2a2ea Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:10:01 +0200 Subject: [PATCH 13/24] feat(backend): add GET /api/documents/{id}/thumbnail endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streams the JPEG thumbnail from S3 with Cache-Control: private, max-age=31536000, immutable — `private` (not `public`) prevents shared caches from leaking one user's thumbnail to another (CWE-525). `immutable` is safe because the URL carries ?v= as a cache-buster that changes whenever the file is replaced. Authentication falls back to the global .anyRequest().authenticated() rule, matching the existing /file endpoint's permission model. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../controller/DocumentController.java | 25 ++++++++ .../controller/DocumentControllerTest.java | 57 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index e5f0bb17..1abee3ad 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -94,6 +94,31 @@ public class DocumentController { } } + // --- THUMBNAIL --- + @GetMapping("/{id}/thumbnail") + public ResponseEntity getDocumentThumbnail(@PathVariable UUID id) { + Document doc = documentService.getDocumentById(id); + + if (doc.getThumbnailKey() == null) { + throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id); + } + + try { + FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey()); + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + // `private` (not `public`) prevents shared caches from serving one user's + // thumbnail to another (CWE-525). `immutable` is safe because the URL + // carries a ?v= cache-buster that changes whenever + // the underlying file is replaced. + .header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable") + .body(download.resource()); + } catch (FileService.StorageFileNotFoundException e) { + throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, + "Thumbnail missing in storage: " + doc.getThumbnailKey()); + } + } + // --- METADATA --- @GetMapping("/{id}") public Document getDocument(@PathVariable UUID id) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 9976a83f..e1282989 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -42,6 +42,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -361,6 +362,62 @@ class DocumentControllerTest { .andExpect(status().isNotFound()); } + // ─── GET /api/documents/{id}/thumbnail ─────────────────────────────────── + + @Test + void getDocumentThumbnail_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/thumbnail")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getDocumentThumbnail_returns404_whenDocHasNoThumbnail() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf").build(); + when(documentService.getDocumentById(id)).thenReturn(doc); + + mockMvc.perform(get("/api/documents/" + id + "/thumbnail")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser + void getDocumentThumbnail_returns200_withPrivateCacheHeader() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf") + .thumbnailKey("thumbnails/" + id + ".jpg").build(); + when(documentService.getDocumentById(id)).thenReturn(doc); + java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + when(fileService.downloadFile("thumbnails/" + id + ".jpg")) + .thenReturn(new FileService.S3FileDownload( + new org.springframework.core.io.InputStreamResource(stream), "image/jpeg")); + + mockMvc.perform(get("/api/documents/" + id + "/thumbnail")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "image/jpeg")) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("private"))) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("public")))) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("immutable"))); + } + + @Test + @WithMockUser + void getDocumentThumbnail_returns404_whenStorageObjectMissing() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf") + .thumbnailKey("thumbnails/" + id + ".jpg").build(); + when(documentService.getDocumentById(id)).thenReturn(doc); + when(fileService.downloadFile("thumbnails/" + id + ".jpg")) + .thenThrow(new FileService.StorageFileNotFoundException("not found")); + + mockMvc.perform(get("/api/documents/" + id + "/thumbnail")) + .andExpect(status().isNotFound()); + } + // ─── POST /api/documents/quick-upload — null/empty files ───────────────── @Test -- 2.49.1 From 547db2fd026c3a49e5b0f7c66dc8c9c4b331f540 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:21:02 +0200 Subject: [PATCH 14/24] test(backend): add ThumbnailServiceIntegrationTest against real MinIO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spins up a MinIO container (Testcontainers GenericContainer) alongside the existing PostgresContainerConfig, uploads a sample PDF, runs the real ThumbnailService, and reads the resulting JPEG back from the object store. Catches S3 signing / path-style access issues a mocked S3Client wouldn't — justifies the CI cost (~45s) per walkthrough T9b. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../ThumbnailServiceIntegrationTest.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java new file mode 100644 index 00000000..492de1e5 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java @@ -0,0 +1,142 @@ +package org.raddatz.familienarchiv.service; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Full round-trip integration test against real MinIO and real Postgres. Catches S3 + * signing / presigning issues that a mocked S3Client would miss — the rest of the + * test pyramid mocks at the FileService boundary. + */ +@SpringBootTest +@Import(PostgresContainerConfig.class) +class ThumbnailServiceIntegrationTest { + + private static final String BUCKET = "archive-documents"; + private static final String ACCESS_KEY = "minioadmin"; + private static final String SECRET_KEY = "minioadmin"; + + static GenericContainer minio = new GenericContainer<>("minio/minio:RELEASE.2024-06-13T22-53-53Z") + .withEnv("MINIO_ROOT_USER", ACCESS_KEY) + .withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY) + .withCommand("server /data") + .withExposedPorts(9000); + + static { + minio.start(); + } + + @DynamicPropertySource + static void s3Properties(DynamicPropertyRegistry registry) { + registry.add("app.s3.endpoint", () -> "http://" + minio.getHost() + ":" + minio.getMappedPort(9000)); + registry.add("app.s3.access-key", () -> ACCESS_KEY); + registry.add("app.s3.secret-key", () -> SECRET_KEY); + registry.add("app.s3.bucket", () -> BUCKET); + registry.add("app.s3.region", () -> "eu-central-1"); + } + + @Autowired S3Client s3Client; + @Autowired ThumbnailService thumbnailService; + @Autowired DocumentRepository documentRepository; + + @Test + void generate_writesDecodableJpegToMinio_readbackMatches() throws IOException { + // Ensure bucket exists (the real app has a bootstrap container for this; in tests we do it here). + // Re-creating is a no-op; wrap in try/catch because the SDK throws on "already owned". + try (S3Client bootstrap = buildClient()) { + try { + bootstrap.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build()); + } catch (Exception ignored) { + // already exists + } + } + + // Persist first so Hibernate assigns the UUID — avoids StaleObjectState on a pre-set id + Document persisted = documentRepository.save(Document.builder() + .title("IT Doc") + .originalFilename("test.pdf") + .status(DocumentStatus.UPLOADED) + .contentType("application/pdf") + .build()); + UUID docId = persisted.getId(); + String pdfKey = "documents/" + docId + "_test.pdf"; + + s3Client.putObject(PutObjectRequest.builder() + .bucket(BUCKET) + .key(pdfKey) + .contentType("application/pdf") + .build(), + RequestBody.fromBytes(createSamplePdf())); + + persisted.setFilePath(pdfKey); + persisted = documentRepository.save(persisted); + + ThumbnailService.Outcome outcome = thumbnailService.generate(persisted); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + + Document reloaded = documentRepository.findById(docId).orElseThrow(); + assertThat(reloaded.getThumbnailKey()).isEqualTo("thumbnails/" + docId + ".jpg"); + assertThat(reloaded.getThumbnailGeneratedAt()).isNotNull(); + + // Read back from MinIO and verify it decodes as a JPEG of the expected width + try (InputStream in = s3Client.getObject(GetObjectRequest.builder() + .bucket(BUCKET).key(reloaded.getThumbnailKey()).build())) { + byte[] jpegBytes = in.readAllBytes(); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(jpegBytes)); + assertThat(decoded).isNotNull(); + assertThat(decoded.getWidth()).isEqualTo(240); + } + } + + private static S3Client buildClient() { + return S3Client.builder() + .endpointOverride(URI.create("http://" + minio.getHost() + ":" + minio.getMappedPort(9000))) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .region(Region.of("eu-central-1")) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .build(); + } + + private static byte[] createSamplePdf() throws IOException { + try (PDDocument pdf = new PDDocument()) { + pdf.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + pdf.save(bos); + return bos.toByteArray(); + } + } +} -- 2.49.1 From 75ae4b6a02283ea09aa004d22b6787cb2b8c498b Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:29:39 +0200 Subject: [PATCH 15/24] feat(frontend): add thumbnailKey and thumbnailGeneratedAt to Document type Mirrors the backend Document entity's new optional fields. Both are optional (no @Schema requiredMode on the backend side), so legacy documents without thumbnails stay valid. Refs #307 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/generated/api.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index ed48f1a2..f2a0f090 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1383,6 +1383,9 @@ export interface components { filePath?: string; contentType?: string; fileHash?: string; + thumbnailKey?: string; + /** Format: date-time */ + thumbnailGeneratedAt?: string; originalFilename: string; /** @enum {string} */ status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; -- 2.49.1 From 0c957972429e0875c5a63f6264a4f9c71f4ab207 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:30:43 +0200 Subject: [PATCH 16/24] feat(frontend): add thumbnailUrl helper with cache-bust param Pure function returning /api/documents/{id}/thumbnail?v= or null when thumbnailKey is missing. The encoded timestamp changes whenever the backend regenerates a thumbnail (file replace), invalidating browser caches despite the immutable Cache-Control. Refs #307 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/thumbnails.test.ts | 37 +++++++++++++++++++++++++++++ frontend/src/lib/thumbnails.ts | 18 ++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 frontend/src/lib/thumbnails.test.ts create mode 100644 frontend/src/lib/thumbnails.ts diff --git a/frontend/src/lib/thumbnails.test.ts b/frontend/src/lib/thumbnails.test.ts new file mode 100644 index 00000000..aad48268 --- /dev/null +++ b/frontend/src/lib/thumbnails.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { thumbnailUrl } from './thumbnails'; + +describe('thumbnailUrl', () => { + it('returns null when thumbnailKey is undefined', () => { + expect(thumbnailUrl({ id: 'abc' })).toBeNull(); + }); + + it('returns url without version param when thumbnailKey present but generatedAt missing', () => { + expect(thumbnailUrl({ id: 'abc', thumbnailKey: 'thumbnails/abc.jpg' })).toBe( + '/api/documents/abc/thumbnail' + ); + }); + + it('appends encoded cache-bust param when generatedAt present', () => { + const url = thumbnailUrl({ + id: 'abc', + thumbnailKey: 'thumbnails/abc.jpg', + thumbnailGeneratedAt: '2026-04-22T20:41:15.123456' + }); + expect(url).toBe('/api/documents/abc/thumbnail?v=2026-04-22T20%3A41%3A15.123456'); + }); + + it('different generatedAt produces different URL — enables cache-bust on file replace', () => { + const a = thumbnailUrl({ + id: 'x', + thumbnailKey: 'thumbnails/x.jpg', + thumbnailGeneratedAt: '2026-01-01T10:00:00' + }); + const b = thumbnailUrl({ + id: 'x', + thumbnailKey: 'thumbnails/x.jpg', + thumbnailGeneratedAt: '2026-01-01T11:00:00' + }); + expect(a).not.toBe(b); + }); +}); diff --git a/frontend/src/lib/thumbnails.ts b/frontend/src/lib/thumbnails.ts new file mode 100644 index 00000000..47a0a606 --- /dev/null +++ b/frontend/src/lib/thumbnails.ts @@ -0,0 +1,18 @@ +type ThumbnailDoc = { + id: string; + thumbnailKey?: string; + thumbnailGeneratedAt?: string; +}; + +/** + * Builds the URL for a document thumbnail image, or returns null when the document + * has no thumbnail yet. When `thumbnailGeneratedAt` is present it is appended as a + * `?v=…` query param so the browser / proxy cache is invalidated whenever the file + * is replaced (the backend regenerates thumbnails at the same S3 key on replace). + */ +export function thumbnailUrl(doc: ThumbnailDoc): string | null { + if (!doc.thumbnailKey) return null; + const base = `/api/documents/${doc.id}/thumbnail`; + if (!doc.thumbnailGeneratedAt) return base; + return `${base}?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`; +} -- 2.49.1 From be184d8faf7fc41a15d276d49be6e39075f92351 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:31:35 +0200 Subject: [PATCH 17/24] feat(frontend): add DocumentThumbnail shared 60x84 tile component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the document thumbnail with object-cover + object-top so letter salutations stay visible, empty alt (title nearby is the accessible name), loading=lazy, decoding=async, and dark:mix-blend-multiply for dark mode. Falls back to a PDF icon when thumbnailKey is null — legacy documents, unsupported content types, or transient failures all land here. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../lib/components/DocumentThumbnail.svelte | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 frontend/src/lib/components/DocumentThumbnail.svelte diff --git a/frontend/src/lib/components/DocumentThumbnail.svelte b/frontend/src/lib/components/DocumentThumbnail.svelte new file mode 100644 index 00000000..d35cfbf7 --- /dev/null +++ b/frontend/src/lib/components/DocumentThumbnail.svelte @@ -0,0 +1,51 @@ + + + +
+ {#if url} + + {:else} + + {/if} +
-- 2.49.1 From 04ebd2a5bdb89a118825c1abbdb1587dc3353124 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:36:20 +0200 Subject: [PATCH 18/24] feat(frontend): render DocumentThumbnail in DocumentRow and PersonDocumentList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home search rows and person detail sidebars now show the real first-page preview when one exists, falling back to the PDF icon for documents the backfill hasn't processed yet. The old `variant` prop on PersonDocumentList is removed — it tinted the icon differently for sent vs received, which no longer applies with a uniform thumbnail tile. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/DocumentRow.svelte | 6 ++++- frontend/src/routes/persons/[id]/+page.svelte | 2 -- .../persons/[id]/PersonDocumentList.svelte | 26 +++++-------------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/components/DocumentRow.svelte b/frontend/src/lib/components/DocumentRow.svelte index 18766320..0bf9fdfa 100644 --- a/frontend/src/lib/components/DocumentRow.svelte +++ b/frontend/src/lib/components/DocumentRow.svelte @@ -6,6 +6,7 @@ import { formatDate } from '$lib/utils/date'; import * as m from '$lib/paraglide/messages.js'; import ProgressRing from './ProgressRing.svelte'; import ContributorStack from './ContributorStack.svelte'; +import DocumentThumbnail from './DocumentThumbnail.svelte'; type DocumentSearchItem = components['schemas']['DocumentSearchItem']; @@ -37,7 +38,10 @@ function safeTagColor(color: string | null | undefined): string {
  • -
    +
    + + +
    diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 05fc412c..64978be9 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -72,14 +72,12 @@ const coCorrespondents = $derived.by(() => { documents={sentDocuments} heading={m.person_docs_heading()} emptyMessage={m.person_no_docs()} - variant="sent" />
    diff --git a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte index cedefbf4..64b71cfb 100644 --- a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte +++ b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte @@ -3,14 +3,14 @@ import { m } from '$lib/paraglide/messages.js'; import { formatDate } from '$lib/utils/date'; import { formatDocumentStatus } from '$lib/utils/documentStatusLabel'; import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort'; +import DocumentThumbnail from '$lib/components/DocumentThumbnail.svelte'; const DOCS_PREVIEW_LIMIT = 5; let { documents, heading, - emptyMessage, - variant = 'sent' + emptyMessage }: { documents: { id: string; @@ -19,10 +19,12 @@ let { documentDate?: string | null; location?: string | null; status: string; + contentType?: string; + thumbnailKey?: string; + thumbnailGeneratedAt?: string; }[]; heading: string; emptyMessage: string; - variant?: 'sent' | 'received'; } = $props(); const yearRange = $derived.by(() => { @@ -42,11 +44,6 @@ const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir)); const visibleDocuments = $derived( showAll ? sortedDocuments : sortedDocuments.slice(0, DOCS_PREVIEW_LIMIT) ); - -// Spec: sent = navy-tint icon bg, received = teal-tint icon bg -const iconClasses = $derived( - variant === 'sent' ? 'bg-primary/10 text-primary' : 'bg-accent/20 text-accent-fg' -);
    @@ -81,17 +78,8 @@ const iconClasses = $derived( href="/documents/{doc.id}" class="group flex items-center gap-3 border-b border-line px-4 py-3 transition-colors last:border-b-0 hover:bg-muted" > - -
    - -
    + +
    -- 2.49.1 From 7bb38004907fe047d95cb97a8074dcd9ab1b671d Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:39:47 +0200 Subject: [PATCH 19/24] feat(frontend): add admin card to generate thumbnails with polling Fourth card on /admin/system mirrors the mass-import pattern: - POST /api/admin/generate-thumbnails to trigger - 2000 ms polling on /api/admin/thumbnail-status while RUNNING - processed / skipped / failed counters in the DONE message - standalone pollInterval so import and thumbnail polling don't interfere with each other Paraglide keys added in de/en/es, mirroring admin_system_import_*. Refs #307 Co-Authored-By: Claude Opus 4.7 --- frontend/messages/de.json | 7 ++ frontend/messages/en.json | 7 ++ frontend/messages/es.json | 7 ++ frontend/src/routes/admin/system/+page.svelte | 109 +++++++++++++++++- 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index f2e1feab..7df88f45 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -338,6 +338,13 @@ "admin_system_import_status_running": "Import läuft…", "admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.", "admin_system_import_status_failed": "Fehler: {message}", + "admin_system_thumbnails_heading": "Thumbnails erzeugen", + "admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).", + "admin_system_thumbnails_btn_start": "Thumbnails erzeugen", + "admin_system_thumbnails_btn_retry": "Erneut starten", + "admin_system_thumbnails_status_running": "Thumbnail-Generierung läuft…", + "admin_system_thumbnails_status_done": "Fertig – {processed} erzeugt, {skipped} übersprungen, {failed} fehlgeschlagen.", + "admin_system_thumbnails_status_failed": "Fehler: {message}", "comp_expandable_show_more": "Mehr anzeigen", "comp_expandable_show_less": "Weniger anzeigen", "error_comment_not_found": "Der Kommentar wurde nicht gefunden.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4a4080d0..dc5cbc93 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -338,6 +338,13 @@ "admin_system_import_status_running": "Import running…", "admin_system_import_status_done": "Import complete – {count} documents processed.", "admin_system_import_status_failed": "Error: {message}", + "admin_system_thumbnails_heading": "Generate thumbnails", + "admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).", + "admin_system_thumbnails_btn_start": "Generate thumbnails", + "admin_system_thumbnails_btn_retry": "Run again", + "admin_system_thumbnails_status_running": "Thumbnail generation running…", + "admin_system_thumbnails_status_done": "Done — {processed} generated, {skipped} skipped, {failed} failed.", + "admin_system_thumbnails_status_failed": "Error: {message}", "comp_expandable_show_more": "Show more", "comp_expandable_show_less": "Show less", "error_comment_not_found": "The comment could not be found.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 08effdff..80ea31a0 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -338,6 +338,13 @@ "admin_system_import_status_running": "Importación en curso…", "admin_system_import_status_done": "Importación completada – {count} documentos procesados.", "admin_system_import_status_failed": "Error: {message}", + "admin_system_thumbnails_heading": "Generar miniaturas", + "admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).", + "admin_system_thumbnails_btn_start": "Generar miniaturas", + "admin_system_thumbnails_btn_retry": "Reiniciar", + "admin_system_thumbnails_status_running": "Generación de miniaturas en curso…", + "admin_system_thumbnails_status_done": "Listo — {processed} generadas, {skipped} omitidas, {failed} fallidas.", + "admin_system_thumbnails_status_failed": "Error: {message}", "comp_expandable_show_more": "Mostrar más", "comp_expandable_show_less": "Mostrar menos", "error_comment_not_found": "El comentario no pudo encontrarse.", diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index 2bc02c83..aa734abf 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -14,8 +14,20 @@ type ImportStatus = { startedAt: string | null; }; +type ThumbnailStatus = { + state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED'; + message: string; + total: number; + processed: number; + skipped: number; + failed: number; + startedAt: string | null; +}; + let importStatus: ImportStatus | null = $state(null); +let thumbnailStatus: ThumbnailStatus | null = $state(null); let pollInterval: ReturnType | null = null; +let thumbnailPollInterval: ReturnType | null = null; function startPolling() { if (pollInterval) return; @@ -29,6 +41,18 @@ function stopPolling() { } } +function startThumbnailPolling() { + if (thumbnailPollInterval) return; + thumbnailPollInterval = setInterval(fetchThumbnailStatus, 2000); +} + +function stopThumbnailPolling() { + if (thumbnailPollInterval) { + clearInterval(thumbnailPollInterval); + thumbnailPollInterval = null; + } +} + async function fetchImportStatus() { const res = await fetch('/api/admin/import-status'); if (res.ok) { @@ -51,11 +75,37 @@ async function triggerImport() { } } +async function fetchThumbnailStatus() { + const res = await fetch('/api/admin/thumbnail-status'); + if (res.ok) { + thumbnailStatus = await res.json(); + if (thumbnailStatus!.state === 'RUNNING') { + startThumbnailPolling(); + } else { + stopThumbnailPolling(); + } + } +} + +async function triggerThumbnails() { + const res = await fetch('/api/admin/generate-thumbnails', { method: 'POST' }); + if (res.ok) { + thumbnailStatus = await res.json(); + if (thumbnailStatus!.state === 'RUNNING') { + startThumbnailPolling(); + } + } +} + $effect(() => { fetchImportStatus(); + fetchThumbnailStatus(); }); -onDestroy(() => stopPolling()); +onDestroy(() => { + stopPolling(); + stopThumbnailPolling(); +}); async function backfillVersions() { backfillLoading = true; @@ -168,5 +218,62 @@ async function backfillFileHashes() { {/if}
    + + +
    +

    + {m.admin_system_thumbnails_heading()} +

    +

    {m.admin_system_thumbnails_description()}

    + + {#if thumbnailStatus?.state === 'RUNNING'} +

    + {m.admin_system_thumbnails_status_running()} + {#if thumbnailStatus.total > 0} + + ({thumbnailStatus.processed + thumbnailStatus.skipped + thumbnailStatus.failed} / + {thumbnailStatus.total}) + + {/if} +

    + {:else if thumbnailStatus?.state === 'DONE'} +

    + {m.admin_system_thumbnails_status_done({ + processed: thumbnailStatus.processed, + skipped: thumbnailStatus.skipped, + failed: thumbnailStatus.failed + })} +

    + + {:else if thumbnailStatus?.state === 'FAILED'} +

    + {m.admin_system_thumbnails_status_failed({ message: thumbnailStatus.message })} +

    + + {:else} + + {/if} +
    -- 2.49.1 From abbb7c798f94d0a6fbd4520069e54a6d90e91228 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:41:58 +0200 Subject: [PATCH 20/24] test(e2e): cover admin thumbnail generation card + a11y - admin.spec: click 'Thumbnails erzeugen', wait for status DONE within 30s, screenshot the success message - accessibility.spec: /admin/system joins the page list so the thumbnail card is checked in light, system-dark, and manual-dark axe-core runs Refs #307 Co-Authored-By: Claude Opus 4.7 --- frontend/e2e/accessibility.spec.ts | 3 ++- frontend/e2e/admin.spec.ts | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 7f6c5921..b8f983fe 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -11,7 +11,8 @@ const AUTHENTICATED_PAGES = [ { name: 'home', path: '/' }, { name: 'persons', path: '/persons' }, { name: 'aktivitaeten', path: '/aktivitaeten' }, - { name: 'admin', path: '/admin' } + { name: 'admin', path: '/admin' }, + { name: 'admin-system', path: '/admin/system' } ]; function buildAxe(page: Parameters[0]['page']) { diff --git a/frontend/e2e/admin.spec.ts b/frontend/e2e/admin.spec.ts index 57682eec..d3bc26f1 100644 --- a/frontend/e2e/admin.spec.ts +++ b/frontend/e2e/admin.spec.ts @@ -248,3 +248,28 @@ test.describe('Admin system tab — backfill file hashes', () => { await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' }); }); }); + +// ─── System tab — generate thumbnails ───────────────────────────────────────── + +test.describe('Admin system tab — generate thumbnails', () => { + test('admin triggers thumbnail generation and sees DONE within 30s', async ({ page }) => { + test.setTimeout(45_000); + + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + // Navigate to System tab + await page.getByRole('button', { name: /system/i }).click(); + + const btn = page + .locator('[data-thumbnails-trigger]') + .or(page.getByRole('button', { name: /thumbnails erzeugen/i })); + await expect(btn.first()).toBeVisible(); + await btn.first().click(); + + // Status transitions RUNNING → DONE; poll message shows the final summary + await expect(page.getByTestId('thumbnails-status-done')).toBeVisible({ timeout: 30_000 }); + + await page.screenshot({ path: 'test-results/e2e/admin-generate-thumbnails.png' }); + }); +}); -- 2.49.1 From 39eaa10d85ef446c6028983f64392c29b1278f17 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:43:27 +0200 Subject: [PATCH 21/24] =?UTF-8?q?docs(adr):=20record=20ADR-004=20=E2=80=94?= =?UTF-8?q?=20PDFBox=20thumbnails=20stay=20in=20Spring=20Boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures why thumbnails render in-process rather than being delegated to ocr-service. Prevents a future reviewer from rehashing the decision or moving it to the Python side without knowing the trade-offs. Refs #307 Co-Authored-By: Claude Opus 4.7 --- docs/adr/004-pdfbox-thumbnails.md | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/adr/004-pdfbox-thumbnails.md diff --git a/docs/adr/004-pdfbox-thumbnails.md b/docs/adr/004-pdfbox-thumbnails.md new file mode 100644 index 00000000..e102a8b7 --- /dev/null +++ b/docs/adr/004-pdfbox-thumbnails.md @@ -0,0 +1,43 @@ +# ADR-004: In-Process PDFBox Thumbnails (not ocr-service) + +## Status + +Accepted + +## Context + +The archive lists documents as text-only rows everywhere (home search, person detail, conversation timeline, Chronik). For a fundamentally visual archive — letters, scans, handwritten pages — this is a real discoverability problem. Issue #307 introduces a small JPEG thumbnail for every document. + +A viable alternative to rendering in Spring Boot is delegating to the existing `ocr-service` (Python), which already has PyMuPDF/PIL available and is the project's designated place for PDF pixel work. The comparison is not obvious: either place works. + +## Decision + +Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (already a dependency for training-data export). A dedicated `thumbnailExecutor` pool isolates the work from the shared task pool used by OCR. + +- PDF first page rendered via `PDFRenderer.renderImageWithDPI(0, 100, ImageType.RGB)`, scaled to 240 px width (bilinear) and encoded as JPEG quality 85. +- Non-PDF image types (JPEG, PNG, TIFF) decoded via `javax.imageio` — TIFF requires the `twelvemonkeys-imageio-tiff` plugin on the classpath. +- Upload paths fire-and-forget via `ThumbnailAsyncRunner.dispatchAfterCommit(docId)`; a `ThumbnailBackfillService` covers anything the async task missed or that pre-dates this feature. + +## Alternatives Considered + +| Alternative | Why rejected | +|---|---| +| Delegate to `ocr-service` (PyMuPDF) | Adds a network hop and a failure mode to every document upload. `ocr-service` is not guaranteed healthy at upload time (model-loading start period is 60 s). PDFBox is already a backend dependency — delegating is a net complexity increase. | +| Render on the frontend with `pdfjs-dist` at display time | Would work for PDFs but not for scans / images; list pages would need to render dozens of PDFs on first paint; no server-side caching. | +| Thumbor / imaginary / a dedicated thumbnail service | Overkill for a single-operator household tool; new container to operate and secure. | + +## Consequences + +**Easier:** +- Zero new infrastructure. `thumbnails/` is a prefix in the existing MinIO bucket — production migration to Hetzner Object Storage works identically. +- Backfill is a plain sequential loop; no inter-service retry semantics. +- Integration test runs against real MinIO without needing `ocr-service` to be healthy. + +**Harder:** +- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload). +- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB. + +## Future Direction + +- If a second image-processing job (OCR region crops, sharing previews) arrives, revisit moving all image work to `ocr-service` so the two share a single PyMuPDF instance. +- If thumbnails ever need to be generated at multiple sizes, switch the key pattern from `thumbnails/{docId}.jpg` to `thumbnails/{docId}/{width}.jpg` — the endpoint and cache-bust URL are already structured to accommodate that. -- 2.49.1 From f137aa79a212532492bf2880a4df6e3e2aca429e Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:58:36 +0200 Subject: [PATCH 22/24] docs(adr): document layering exception and in-memory backfill state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @mkeller (Markus) — fixes(adr): "the ADR doesn't mention in-memory BackfillStatus" and "treat this as a layering exception, acknowledge it explicitly". Two new paragraphs under Operational caveats. Refs #307 Co-Authored-By: Claude Opus 4.7 --- docs/adr/004-pdfbox-thumbnails.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/adr/004-pdfbox-thumbnails.md b/docs/adr/004-pdfbox-thumbnails.md index e102a8b7..4d799545 100644 --- a/docs/adr/004-pdfbox-thumbnails.md +++ b/docs/adr/004-pdfbox-thumbnails.md @@ -37,6 +37,12 @@ Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (alrea - PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload). - Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB. +### Operational caveats (intentional) + +**Backfill state is in-memory and single-node.** `ThumbnailBackfillService.currentStatus` is a volatile reference updated on the thumbnail executor thread. Restarting the backend mid-run loses progress and the next `runBackfillAsync()` starts over. This mirrors `MassImportService.ImportStatus` and is acceptable because the household archive runs as a single Spring Boot process, backfill is a rare one-shot admin action, and re-running the backfill is idempotent (`findByFilePathIsNotNullAndThumbnailKeyIsNull()` naturally skips completed documents). + +**`ThumbnailService` and `ThumbnailBackfillService` inject `DocumentRepository` directly.** This is a deliberate exception to the project's "services never reach into another domain's repository" rule. Treating thumbnails as a cross-cutting aspect of `Document` rather than a sub-domain avoids a circular dependency (`DocumentService` → `ThumbnailAsyncRunner` → `DocumentService` would close the loop). If thumbnail state grows beyond two columns into its own domain model, extract a proper `ThumbnailRepository` at that point — not before. + ## Future Direction - If a second image-processing job (OCR region crops, sharing previews) arrives, revisit moving all image work to `ocr-service` so the two share a single PyMuPDF instance. -- 2.49.1 From f0f9753c42fa8de7e98d06a1ae281469500cb20c Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 23:01:50 +0200 Subject: [PATCH 23/24] refactor(backend): split ThumbnailService.generate into stages with distinct logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @felixbrandt — fix(backend): "the two try blocks in generate() overlap — a save failure logs 'generation failed' even though the thumbnail is already in S3 as an orphan". generate() now orchestrates four stages, each in its own try+log: readSourceImage / encodeThumbnail / uploadToStorage / persistThumbnailMetadata persistThumbnailMetadata emits the distinct "orphaned in storage as " log line so an operator can see database-side failures after the upload completed. The deterministic key ensures the next run overwrites cleanly, so the orphan is self-healing. Also extracts THUMBNAIL_KEY_PREFIX/SUFFIX constants with a comment explaining the deterministic-overwrite contract. Adds test: generate_returnsFailed_whenPersistThrows_butUploadSucceeded. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailService.java | 64 ++++++++++++++++--- .../service/ThumbnailServiceTest.java | 18 ++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java index 465ff20d..3b4cb48e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -27,6 +27,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.LocalDateTime; import java.util.Set; +import java.util.UUID; /** * Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image) @@ -48,6 +49,13 @@ public class ThumbnailService { private static final Set IMAGE_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/tiff"); + // Deterministic S3 key — `thumbnails/{docId}.jpg`. When a document's file is replaced + // the regenerated thumbnail overwrites this same key via PutObject, so we never + // orphan old thumbnails. The URL-level cache buster is the `thumbnail_generated_at` + // timestamp (see /api/documents/{id}/thumbnail ?v= param). + private static final String THUMBNAIL_KEY_PREFIX = "thumbnails/"; + private static final String THUMBNAIL_KEY_SUFFIX = ".jpg"; + private final FileService fileService; private final S3Client s3Client; private final DocumentRepository documentRepository; @@ -74,21 +82,47 @@ public class ThumbnailService { return Outcome.SKIPPED; } - BufferedImage source; + BufferedImage source = readSourceImage(doc, contentType); + if (source == null) return Outcome.FAILED; + + byte[] jpeg = encodeThumbnail(source, doc.getId()); + if (jpeg == null) return Outcome.FAILED; + + String thumbnailKey = thumbnailKeyFor(doc.getId()); + if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED; + + return persistThumbnailMetadata(doc, thumbnailKey); + } + + private static String thumbnailKeyFor(UUID documentId) { + return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX; + } + + private BufferedImage readSourceImage(Document doc, String contentType) { try { - source = PDF_CONTENT_TYPE.equals(contentType) + return PDF_CONTENT_TYPE.equals(contentType) ? renderPdfFirstPage(doc.getFilePath()) : readImage(doc.getFilePath()); } catch (Exception e) { - log.warn("Thumbnail generation failed for doc={} reason={}", + log.warn("Thumbnail source read failed for doc={} reason={}", doc.getId(), e.getMessage()); - return Outcome.FAILED; + return null; } + } + private byte[] encodeThumbnail(BufferedImage source, UUID documentId) { try { BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH); - byte[] jpeg = encodeJpeg(scaled, JPEG_QUALITY); - String thumbnailKey = "thumbnails/" + doc.getId() + ".jpg"; + return encodeJpeg(scaled, JPEG_QUALITY); + } catch (Exception e) { + log.warn("Thumbnail JPEG encoding failed for doc={} reason={}", + documentId, e.getMessage()); + return null; + } + } + + private boolean uploadToStorage(String thumbnailKey, byte[] jpeg, UUID documentId) { + try { s3Client.putObject( PutObjectRequest.builder() .bucket(bucketName) @@ -96,14 +130,28 @@ public class ThumbnailService { .contentType("image/jpeg") .build(), RequestBody.fromBytes(jpeg)); + return true; + } catch (Exception e) { + log.warn("Thumbnail upload failed for doc={} key={} reason={}", + documentId, thumbnailKey, e.getMessage()); + return false; + } + } + private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey) { + try { doc.setThumbnailKey(thumbnailKey); doc.setThumbnailGeneratedAt(LocalDateTime.now()); documentRepository.save(doc); return Outcome.SUCCESS; } catch (Exception e) { - log.warn("Thumbnail generation failed for doc={} reason={}", - doc.getId(), e.getMessage()); + // Thumbnail is already in S3 but the entity update failed. Because the S3 + // key is deterministic (thumbnails/{docId}.jpg), the next successful run + // — either a re-upload of this document or the admin backfill — will + // overwrite it cleanly. Logging distinctly so an operator tracking + // backfill totals can spot the database-side issue. + log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}", + doc.getId(), thumbnailKey, e.getMessage()); return Outcome.FAILED; } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java index bf6827af..90b0e8c8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -167,6 +167,24 @@ class ThumbnailServiceTest { verify(documentRepository, never()).save(any()); } + @Test + void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException { + // Covers the "orphan thumbnail" edge case: S3 upload succeeded but the + // entity update blew up. We must still return FAILED so the backfill + // tally is honest, without losing the fact that we already put bytes in S3. + Document doc = makeDoc("application/pdf", "documents/letter.pdf"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePdf())); + when(documentRepository.save(any())) + .thenThrow(new RuntimeException("constraint violation")); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + verify(documentRepository).save(any()); + } + // ─── helpers ────────────────────────────────────────────────────────────── private Document makeDoc(String contentType, String filePath) { -- 2.49.1 From b6bfb9148e68c48e8ccbea69a425c7b06e8b9657 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 23:03:19 +0200 Subject: [PATCH 24/24] fix(frontend): use generic document icon for thumbnail fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @leonievoss and @felixbrandt — fix(ui): "the PDF icon misleads for image documents" and "swap for a neutral file icon". The fallback now shows a generic document-text glyph (page outline + three text lines) instead of the PDF-specific icon with the folded corner. Applies equally well to PDFs, JPEG/PNG scans, and TIFF documents — all of which can land in the fallback path. Also bumped the icon from h-6/w-6 to h-8/w-8 — the previous 24px glyph looked sparse inside the 60×84 tile (Leonie, post-merge iteration point #2). Refs #307 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/components/DocumentThumbnail.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/DocumentThumbnail.svelte b/frontend/src/lib/components/DocumentThumbnail.svelte index d35cfbf7..fe7050c9 100644 --- a/frontend/src/lib/components/DocumentThumbnail.svelte +++ b/frontend/src/lib/components/DocumentThumbnail.svelte @@ -32,18 +32,21 @@ const url = $derived(thumbnailUrl(doc)); /> {:else} -- 2.49.1