feature: PDF-Thumbnails für Dokumente (Upload + Admin-Backfill) #308

Merged
marcel merged 24 commits from feat/issue-307-pdf-thumbnails into main 2026-04-23 07:11:23 +02:00
37 changed files with 1767 additions and 29 deletions

View File

@@ -164,12 +164,19 @@
<version>3.0.2</version>
</dependency>
<!-- PDF rendering for training data export -->
<!-- PDF rendering for training data export and thumbnail generation -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.4</version>
</dependency>
<!-- TIFF decoding plugin for ImageIO (thumbnail generation from scanned TIFFs) -->
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-tiff</artifactId>
<version>3.12.0</version>
</dependency>
</dependencies>

View File

@@ -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;
}
}

View File

@@ -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<MassImportService.ImportStatus> triggerMassImport() {
@@ -47,4 +49,15 @@ public class AdminController {
int count = documentService.backfillFileHashes();
return ResponseEntity.ok(new BackfillResult(count));
}
@PostMapping("/generate-thumbnails")
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
thumbnailBackfillService.runBackfillAsync();
return ResponseEntity.accepted().body(thumbnailBackfillService.getStatus());
}
@GetMapping("/thumbnail-status")
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> thumbnailStatus() {
return ResponseEntity.ok(thumbnailBackfillService.getStatus());
}
}

View File

@@ -94,6 +94,31 @@ public class DocumentController {
}
}
// --- THUMBNAIL ---
@GetMapping("/{id}/thumbnail")
public ResponseEntity<InputStreamResource> 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=<thumbnailGeneratedAt> 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) {

View File

@@ -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,

View File

@@ -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)

View File

@@ -46,6 +46,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
List<Document> findByFilePathIsNotNullAndThumbnailKeyIsNull();
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);

View File

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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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<Document> 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<ThumbnailService.Outcome> 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();
}
}
}

View File

@@ -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<Document> 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);
}
}

View File

@@ -0,0 +1,207 @@
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;
import java.util.UUID;
/**
* 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<String> 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;
@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 = 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 {
return PDF_CONTENT_TYPE.equals(contentType)
? renderPdfFirstPage(doc.getFilePath())
: readImage(doc.getFilePath());
} catch (Exception e) {
log.warn("Thumbnail source read failed for doc={} reason={}",
doc.getId(), e.getMessage());
return null;
}
}
private byte[] encodeThumbnail(BufferedImage source, UUID documentId) {
try {
BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH);
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)
.key(thumbnailKey)
.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) {
// 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;
}
}
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();
}
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE documents
ADD COLUMN thumbnail_key VARCHAR(255),
ADD COLUMN thumbnail_generated_at TIMESTAMP;

View File

@@ -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));
}
}

View File

@@ -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

View File

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

View File

@@ -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<GetObjectResponse> 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");
}
}

View File

@@ -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);

View File

@@ -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<TransactionSynchronization> 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,250 @@
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<PutObjectRequest> putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
ArgumentCaptor<RequestBody> 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<RequestBody> 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<RequestBody> 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());
}
@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) {
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();
}
}
}

View File

@@ -0,0 +1,49 @@
# 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.
### 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.
- 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.

View File

@@ -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<typeof AxeBuilder>[0]['page']) {

View File

@@ -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' });
});
});

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 {
<li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block px-4 py-4 sm:py-5">
<div class="flex gap-0 sm:gap-5">
<div class="flex gap-3 sm:gap-5">
<!-- Thumbnail tile -->
<DocumentThumbnail doc={doc} />
<!-- Left column -->
<div class="flex-1 sm:border-r sm:border-line sm:pr-5">
<!-- Title -->

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { thumbnailUrl } from '$lib/thumbnails';
type Doc = Pick<
components['schemas']['Document'],
'id' | 'thumbnailKey' | 'thumbnailGeneratedAt' | 'contentType'
>;
let { doc }: { doc: Doc } = $props();
const url = $derived(thumbnailUrl(doc));
</script>
<!--
Fixed 60x84 tile used wherever a document row appears. When the backend has
generated a thumbnail we render it with `object-cover` + `object-top` so
letter salutations stay visible; otherwise we fall back to the file-type
icon so the row never shows an empty rectangle. Dark mode uses
`mix-blend-multiply` to keep bright paper scans from glaring against the
dark page background.
-->
<div
class="relative h-[84px] w-[60px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
>
{#if url}
<img
src={url}
alt=""
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
loading="lazy"
decoding="async"
/>
{:else}
<div class="flex h-full w-full items-center justify-center text-ink-3" aria-hidden="true">
<!-- Generic document icon (heroicons document-text outline). Shown when the
thumbnail hasn't been generated yet — applies equally to PDFs and to
image scans, so we deliberately avoid a PDF-specific glyph here. -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-8 w-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z M9 12.75h6M9 15.75h6M9 18.75h3"
/>
</svg>
</div>
{/if}
</div>

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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)}`;
}

View File

@@ -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<typeof setInterval> | null = null;
let thumbnailPollInterval: ReturnType<typeof setInterval> | 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() {
</button>
{/if}
</div>
<!-- Thumbnail backfill -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 font-sans text-sm font-bold text-ink">
{m.admin_system_thumbnails_heading()}
</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_thumbnails_description()}</p>
{#if thumbnailStatus?.state === 'RUNNING'}
<p class="text-sm text-ink-2">
{m.admin_system_thumbnails_status_running()}
{#if thumbnailStatus.total > 0}
<span class="text-ink-3">
({thumbnailStatus.processed + thumbnailStatus.skipped + thumbnailStatus.failed} /
{thumbnailStatus.total})
</span>
{/if}
</p>
{:else if thumbnailStatus?.state === 'DONE'}
<p
data-testid="thumbnails-status-done"
class="mb-4 rounded-sm border border-green-200 bg-green-50 p-3 text-sm text-green-700"
>
{m.admin_system_thumbnails_status_done({
processed: thumbnailStatus.processed,
skipped: thumbnailStatus.skipped,
failed: thumbnailStatus.failed
})}
</p>
<button
data-thumbnails-trigger
onclick={triggerThumbnails}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_thumbnails_btn_retry()}
</button>
{:else if thumbnailStatus?.state === 'FAILED'}
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{m.admin_system_thumbnails_status_failed({ message: thumbnailStatus.message })}
</p>
<button
data-thumbnails-trigger
onclick={triggerThumbnails}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_thumbnails_btn_retry()}
</button>
{:else}
<button
data-thumbnails-trigger
onclick={triggerThumbnails}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_thumbnails_btn_start()}
</button>
{/if}
</div>
</div>
</div>

View File

@@ -72,14 +72,12 @@ const coCorrespondents = $derived.by(() => {
documents={sentDocuments}
heading={m.person_docs_heading()}
emptyMessage={m.person_no_docs()}
variant="sent"
/>
<PersonDocumentList
documents={receivedDocuments}
heading={m.person_received_docs_heading()}
emptyMessage={m.person_no_received_docs()}
variant="received"
/>
</div>
</div>

View File

@@ -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'
);
</script>
<div class="mb-4 rounded-sm border border-line bg-surface">
@@ -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"
>
<!-- Tinted doc icon -->
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded {iconClasses}"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
</div>
<!-- Thumbnail tile -->
<DocumentThumbnail doc={doc} />
<!-- Title + meta -->
<div class="min-w-0 flex-1">