feature: PDF-Thumbnails für Dokumente (Upload + Admin-Backfill) #307
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PDF Thumbnail Support
Context
Documents are shown as text-only rows across the app (home search,
PersonDocumentList,ConversationTimeline, Chronik). At a glance there is no visual cue for "which document is this" — every row looks the same. For an archive that is fundamentally visual (handwritten letters, typed pages, photos), this is a real discoverability problem. Specs already exist for the target UI (docs/specs/briefwechsel-thumbnail-rows-spec.html).This plan adds a small JPEG preview (first page for PDFs, scaled-down version for images) that is:
/admin/system,Documententity so the frontend can render it wherever documents are listed.Takes on approach
A few judgment calls worth flagging up front — these are the sensible defaults.
thumbnails/<docId>.jpgprefix. One bucket already exists; adding a prefix is simpler than a second bucket and keeps the deploy story identical. Deterministic key (no UUID suffix) so regenerating after a file replace overwrites cleanly.javax.imageio— no new dependency. If WebP is desired later, it is a one-line change in the generator (swap the writer).pom.xml(used for training data export).PDFRenderer.renderImageWithDPI(0, 100)gives the first page. Zero new backend dependencies.@Asyncon the reusabletaskExecutor. Same pattern asOcrAsyncRunner— failure is logged, upload succeeds regardless. Backfill covers any doc the async task never got to.MassImportServiceexactly. In-memoryThumbnailBackfillStatus(IDLE/RUNNING/DONE/FAILED with processed/skipped/failed counters), polled every 2s by the admin UI. No new persistent job entity — overkill for a single-operator admin tool and inconsistent with existing admin backfills.thumbnailKey+thumbnailGeneratedAtdirectly onDocument. Two columns, no extra join, and the frontend already receives the whole Document on every endpoint.thumbnailGeneratedAtdoubles as a cache-buster in the URL.GET /api/documents/{id}/thumbnailstreams from S3 withCache-Control: public, max-age=31536000, immutable(the URL changes whenthumbnailGeneratedAtchanges). Keeps auth consistent with/fileand avoids presign complexity in<img>tags.Backend changes
1. Data model (Flyway + entity)
backend/src/main/resources/db/migration/V39__add_document_thumbnails.sql(new)backend/src/main/java/org/raddatz/familienarchiv/model/Document.java— add two nullable fields afterfileHash:Both nullable → no
@Schema(requiredMode = REQUIRED), meaning they appear asstring | undefinedin the generated TS types.2. New service:
ThumbnailServicebackend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java(new)Responsibilities:
generate(Document doc)— downloads original viaFileService.downloadFileBytes(), renders preview, uploads tothumbnails/{docId}.jpgviaS3Client, setsthumbnailKey+thumbnailGeneratedAton the document, saves.doc.getContentType():application/pdf→ PDFBoxLoader.loadPDF(bytes)→PDFRenderer.renderImageWithDPI(0, 100, ImageType.RGB)image/jpeg,image/png,image/tiff→ImageIO.read(new ByteArrayInputStream(bytes))BufferedImageto width=400 preserving aspect ratio (simpleImage.getScaledInstance+drawImage, no extra library).ByteArrayOutputStreamviaImageIO.write(scaled, "jpg", out).S3Clientbean withPutObjectRequest.contentType("image/jpeg").Explicitly does not throw — logs and returns. Upload paths must not fail because of thumbnails.
3. Async runner:
ThumbnailAsyncRunnerbackend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java(new) — mirrorsOcrAsyncRunner.4. Hook into upload paths
In
DocumentService— after eachfileService.uploadFile(...)call and the subsequent save, triggerthumbnailAsyncRunner.generateAsync(saved.getId()). Four sites:storeDocumentcreateDocumentupdateDocument(only whennewFile != null)attachFileIn
MassImportService.importSingleDocument— same hook after each file upload.Inject
ThumbnailAsyncRunnervia constructor (@RequiredArgsConstructorhandles it).5. Backfill service
backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java(new) — copy the shape ofMassImportService:Add repository method on
DocumentRepository:6. Error code
exception/ErrorCode.java— addTHUMBNAIL_BACKFILL_ALREADY_RUNNING.frontend/src/lib/errors.ts— mirror it, add a Paraglide key inmessages/{de,en,es}.json.7. Controller endpoints
AdminController(append):DocumentController(append, mirroring/file):8. Tests (TDD — red → green)
ThumbnailServiceTest— unit test with a tiny fixture PDF (src/test/resources/fixtures/sample.pdf) and a fixture PNG. Assert S3 put is called with the correct key and bytes are a decodable JPEG of width 400.ThumbnailBackfillServiceTest— mockThumbnailService, verify it is called for each document without athumbnailKey, that status transitions match theMassImportServicepattern, and that unsupported content-types incrementskipped.AdminControllerTest— slice test for the new endpoints (permission required, status shape).DocumentServiceTest— verifythumbnailAsyncRunner.generateAsync(id)is called after each of the four upload paths.Frontend changes
1. Regenerate API types
After the backend is rebuilt with
-DskipTestsand running in dev:This adds
thumbnailKey?: stringandthumbnailGeneratedAt?: stringto the generatedDocumenttype.2. Thumbnail URL helper
frontend/src/lib/thumbnails.ts(new) — one small function:The
?v=query param guarantees cache invalidation when a file is replaced.3. Document list rows
frontend/src/lib/components/DocumentRow.svelte— insert a 60×84px thumbnail column before the title column. WhenthumbnailUrl(doc)returns null, keep the existing PDF icon as fallback. Useloading="lazy"on the<img>; border/radius matches thebrand-sandcard style.frontend/src/routes/persons/[id]/PersonDocumentList.svelte— replace the small PDF icon tile with the thumbnail when available (same fallback pattern).For
ConversationTimeline.svelteandChronikRow.svelte: out of scope for this iteration. The specs exist but each is its own layout problem — address in follow-ups once the core data plumbing is proven.4. Admin system page — new card
frontend/src/routes/admin/system/+page.svelte— add a fourth card after "Mass Import" mirroring its structure exactly:POST /api/admin/generate-thumbnailsGET /api/admin/thumbnail-statusevery 2000 ms while state isRUNNING(reuse thefetchImportStatuspattern)5. E2E sanity check
Existing tests should keep passing. Add one Playwright test under
frontend/e2e/that uploads a PDF, waits briefly, and asserts the thumbnail image loads (HTTP 200 from/api/documents/{id}/thumbnail).Critical files
New
backend/src/main/resources/db/migration/V39__add_document_thumbnails.sqlbackend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.javabackend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.javabackend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.javabackend/src/test/java/.../ThumbnailServiceTest.javabackend/src/test/java/.../ThumbnailBackfillServiceTest.javafrontend/src/lib/thumbnails.tsModified
backend/src/main/java/org/raddatz/familienarchiv/model/Document.java— two new columnsbackend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java— inject runner, four hooksbackend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java— one hook inimportSingleDocumentbackend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java— new finderbackend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java— two endpointsbackend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java—/thumbnailendpointbackend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java— new codefrontend/src/lib/generated/api.ts— regeneratedfrontend/src/lib/errors.ts+messages/{de,en,es}.json— new code translationfrontend/src/lib/components/DocumentRow.svelte— thumbnail columnfrontend/src/routes/persons/[id]/PersonDocumentList.svelte— thumbnail tilefrontend/src/routes/admin/system/+page.svelte— fourth cardReused (read-only)
FileService(uploadFile,downloadFileBytes,downloadFile) — no changesAsyncConfig.taskExecutor()— same pool as OCROcrAsyncRunner— structural templateMassImportService— state-machine and status templateVerification
End-to-end checklist once implemented:
./mvnw test— all new and existing backend tests pass.docker-compose up -dthen./mvnw spring-boot:run -Dspring-boot.run.profiles=dev.POST /api/documentswith a multipart PDF. Response returns immediately; wait ~1 s;GET /api/documents/{id}showsthumbnailKeypopulated;GET /api/documents/{id}/thumbnailreturns a JPEG withCache-Control: immutable.PUT /api/documents/{id}with new file):thumbnailGeneratedAtchanges, cache-busting URL updates, new thumbnail visible.thumbnail_keyin DB manually;POST /api/admin/generate-thumbnails; poll/api/admin/thumbnail-statusuntilDONE; verify the doc has a new thumbnail.THUMBNAIL_BACKFILL_ALREADY_RUNNING.application/octet-stream): counted inskipped, no error, logged.npm run generate:api && npm run dev. Check home search shows thumbnails in rows; person detail shows thumbnails in the sidebar list; admin system page shows the new card and polling works.npm run test:e2e -- thumbnailpasses.🏛️ Markus Keller — Senior Application Architect
Good plan overall — reuses what already exists (PDFBox,
taskExecutor,MassImportServicestate-machine pattern) and introduces only a focused data-model change. A few architectural concerns before implementation:Observations
@Transactionalmethod.DocumentService.storeDocument,createDocument,updateDocument,attachFileare all@Transactional. CallingthumbnailAsyncRunner.generateAsync(saved.getId())from inside them means the async thread can start before the transaction commits — it willfindByIdand either see nothing or stale data. The codebase already has the right tool for this:AuditService.logAfterCommit(...)usesTransactionSynchronizationManager.registerSynchronizationwithafterCommit(). That is the pattern to mirror.AsyncConfig.taskExecutor()is core=2, max=2, queue=10,AbortPolicy. ThePOST /api/documents/quick-uploadendpoint is a batch upload — uploading 15 files at once overflows the queue and throwsRejectedExecutionException. This plan adds thumbnail generation as a second async consumer of that same pool (today it's used byOcrAsyncRunner).volatile BackfillStatuspublishes the reference correctly but the record fields areint, and updating counters by creating a new record each iteration is a read-modify-write sequence. Single-threaded backfill is fine (the loop runs on one async thread), but make it explicit so it doesn't get "optimized" later to parallel processing.MassImportServicehas the same limitation — acceptable for a single-operator tool, but worth naming in an ADR so the next maintainer knows it's intentional.Recommendations
Register
afterCommit()synchronization instead of calling the runner directly. Extract a small helper:Follow the
AuditService.logAfterCommitshape exactly — it already handles the "we're not in a transaction" edge case.Add a dedicated
thumbnailExecutorbean. Don't share withtaskExecutor. Configure e.g.core=1, max=2, queue=200, CallerRunsPolicy. Quick-upload of 15 files should not be the same contention domain as OCR streaming.CallerRunsPolicyapplies back-pressure instead of dropping work. Put this next to the existingauditExecutorinAsyncConfig.javaand annotate@Async("thumbnailExecutor").Backfill runs on the thumbnail executor with explicit sequential loop. One thumbnail job at a time, one doc at a time. PDFBox +
downloadFileBytesis CPU + memory-heavy — concurrency here buys nothing but OOM risk.Write the ADR. "Why thumbnails are in-process with PDFBox, not delegated to ocr-service" is exactly the kind of decision that gets second-guessed in 18 months. Two paragraphs in
docs/adr/: the constraint (keep backend self-sufficient, avoid cross-service latency on upload), the alternative (ocr-service with PyMuPDF — capable but adds network dependency to the critical upload path), the consequence (acceptable memory ceiling for typical family-archive PDF sizes, <50MB).Open Decisions
public, immutable, max-age=1 yearis wrong for a per-user authenticated resource — a shared proxy could serve one user's thumbnail to another. This is really Nora's call; flagging here because it's an architecture-level decision about caching semantics, not just a security fix.👨💻 Felix Brandt — Senior Fullstack Developer
Plan reads cleanly and the TDD sequence in §8 is the right shape. A few implementation concerns that a red test will force you to confront early:
Observations
fileService.downloadFileBytes()loads the full PDF into abyte[]. For a 50MB PDF (currentmax-file-size), that is 50MB per concurrent thumbnail task. Two threads × 50MB × an intermediateBufferedImagecopy = real peaks. The method is annotated "callers are responsible for not calling this on large files unnecessarily" — that caveat applies here.@Transactionalis the classic race (see Markus's note). Use theafterCommitpattern, mirrored fromAuditService.logAfterCommit. I will fail-fast on this in review.thumbnails/{docId}.jpgis a deterministic key — good. ButPutObjectwithout any content-disposition means a strayGETresponse on the thumbnail endpoint could default toapplication/octet-streamif the frontend ever probes it outside our controller. Set the content type on put and letdownloadFile()echo it back.?v=${thumbnailGeneratedAt}cache-buster serializes aLocalDateTimeas2026-04-22T20:41:15.123456with colons and dots.encodeURIComponentwill escape the colons to%3A. That works, but the key stays more readable if you use.getTime()on anInstant/ epoch millis on the backend DTO side. Or simpler: exposethumbnailVersion: number(epoch millis) and leavethumbnailGeneratedAtas the human-readable field.DocumentService.storeDocumentis one of the four sites. The plan already lists it — just wanted to confirm thatPOST /api/documents/quick-uploadflows through there.Recommendations
Use
TransactionSynchronizationManager— code sketch, extract into a helper:Four callsites in
DocumentService+ one inMassImportService. Unit test: verify the runner is NOT called when the surrounding transaction rolls back (e.g. by throwing after save).Stream the PDF into PDFBox instead of buffering all bytes.
Loader.loadPDF(RandomAccessReadBuffer)accepts anInputStream. Add aFileService.downloadFileStream(s3Key)that returns the S3ResponseInputStreamand use try-with-resources. PDFBox only reads the pages it needs; you don't need the whole file in heap. For images,ImageIO.read(InputStream)works too.Scale with
Thumbnails.of(src).width(400).outputFormat("jpg")— or plainAffineTransformOpwithTYPE_BILINEAR. Do not useImage.getScaledInstance. It uses deprecated multi-pass AWT scaling that's slower and produces worse output than a singledrawImageonto a newBufferedImage. Standard Java, zero dependency:TIFF decoding needs
twelvemonkeys-imageio-tiff— JDK's ImageIO only handles JPEG/PNG/BMP/GIF by default. Plan calls out TIFF support; add the dependency.Width 400 is twice what's needed. Display is 60×84 CSS pixels → 120×168 at 2× DPR. 200–240px wide is plenty and halves the bytes. Agree with Leonie on the final number.
Red test first, always. Four tests that must be red before any code:
src/test/resources/fixtures/)putObjectis called with keythumbnails/{docId}.jpgand content typeimage/jpegTransactionSynchronizationManagerafterCommit path fires on successful commit, not on rollbackDocumentService.storeDocumentdispatches exactly once per call (verify with Mockitoverify(thumbnailAsyncRunner, times(1)).generateAsync(...))Frontend
thumbnails.tshelper: keep it a pure function, no$derived— it's imported into multiple components (DocumentRow, PersonDocumentList). Colocate the test next to it.🔒 Nora "NullX" Steiner — Application Security Engineer
Solid plan with a security review needed on three specific items before merge. None are showstoppers; all have concrete fixes.
Observations
Cache-Control: public, max-age=31536000, immutableon an authenticated endpoint is a CWE-525 cache leak.publicinstructs shared caches (corporate reverse proxies, ISP caches, CDN edge) to cache and serve the response to any requester. If anything between the user and your Caddy ever introduces caching with authentication stripped, User A's thumbnail (which may contain the first page of a private letter with PII) gets served to User B. For a household archive on a home LAN this is low exposure today, but the fix is one word:Every authenticated resource in this codebase should use
private. The existing/api/documents/{id}/fileendpoint doesn't set Cache-Control at all (which is also imperfect — defaults to no caching, so no leak). Setting it toprivate, immutableon thumbnail is strictly better.GET /api/documents/{id}/thumbnailhas no explicit@RequirePermission. I see the existingGET /api/documents/{id}/filealso has no annotation — it falls back to Spring Security's.anyRequest().authenticated(). That is consistent but still means: any authenticated user (including a low-privilegeREAD_ALLrole) can retrieve any document's thumbnail by UUID. For the current household scope this is acceptable. If you ever introduce per-document ACLs, both endpoints break. Flagging for future-proofing, not as a blocker — pair with the existing/fileendpoint's behavior.PDFBox is a parser attack surface. Malicious PDFs (crafted by an uploader) can trigger: unbounded memory on embedded images, infinite loops on corrupt xrefs, historical CVEs. For a family archive where uploaders are trusted household members this is near-zero real risk. But the plan doesn't call out any resource limits. Low-cost mitigation:
thumbnailService.generate(doc)in a timeout (e.g.Executors.newSingleThreadExecutor().submit(...).get(30, TimeUnit.SECONDS))The deterministic key
thumbnails/{docId}.jpgis fine. UUIDs are unguessable; no risk of pre-populating an attacker-controlled key. No SSRF concerns — rendering happens against bytes we already own.Recommendations
Change
public→privatein the Cache-Control header. One-char fix, zero downside. Write the regression test:That test stays forever — it catches future regressions.
Add a 30-second timeout around
thumbnailService.generate()inThumbnailAsyncRunner. Protection against a crafted PDF that makes PDFBox hang. Counts as "failed" in backfill status.Bound PDFBox memory via
IOUtils.setByteArrayMaxOverrideis a no-op in PDFBox 3 (API changed). UseLoader.loadPDF(RandomAccessReadBuffer)with a size-limited input stream wrapper. Cap at e.g. 100MB — already bounded bymax-file-size: 50MB, so this is defense-in-depth.Permission test regardless of whether we add
@RequirePermission:Both stay in the regression suite permanently.
🧪 Sara Holt — QA & Test Strategist
Test plan in §8 is in the right direction. Coverage gaps I want addressed before merge:
Observations
afterCommitsemantics, it regresses the moment someone "simplifies" the dispatcher.failed, loop continues), concurrent start rejection (covered by the plan — good).src/test/resources/fixtures/. The PDF must actually be renderable by PDFBox — generate it with PDFBox itself in a one-shot script and commit the output.Recommendations
Add
ThumbnailDispatchTransactionTestwith these exact cases:These two tests defend the dispatcher contract.
Add
DocumentService_fileReplacement_regeneratesThumbnailtest. Put a fixture doc with thumbnailKey set, callupdateDocumentwith a new file, verifythumbnailAsyncRunner.generateAsync(docId)was called.Testcontainers for the migration and repository tests. The new
findByFilePathIsNotNullAndThumbnailKeyIsNull()finder needs a real Postgres to validate the JPA derivation.Backfill failure scenarios:
@SpyonThumbnailService, throwStorageFileNotFoundExceptionon the 2nd doc; assertfailedcounter = 1,processed= remaining docs, loop completed.content_type = application/msword→skippedcounter increments, no exception.runBackfillAsync()twice, expect the second to throwDomainException.conflict(THUMBNAIL_BACKFILL_ALREADY_RUNNING).getStatus()mid-loop (viaCountDownLatch), assertstate == RUNNINGandprocessed < total.One Playwright E2E on the admin card — happy path only. Click button, assert status transitions to RUNNING then DONE within 10 seconds (
await expect(page.getByText(/fertig/i)).toBeVisible({ timeout: 10_000 })). UseAxeBuilderon the admin page — new card content must not introduce a11y violations.Coverage gate — the full test plan must keep branch coverage ≥ 80%. PDFBox rendering branches (PDF vs. image vs. unsupported) need explicit tests; these are likely
if/switchbranches that coverage will track.Open Decisions
docker-compose.ci.yml, or mock at theFileServiceboundary? Real MinIO is more faithful; mocking is faster. I lean real-MinIO in one test (the endpoint serves actual bytes), mocked elsewhere. Tobias should weigh in on CI runtime cost.⚙️ Tobias Wendt — DevOps & Platform Engineer
Plan is reasonable for our single-VPS setup. Four operational concerns.
Observations
taskExecutoris core=2 / max=2 / queue=10 / AbortPolicy. Adding thumbnail generation as a second async consumer (alongsideOcrAsyncRunner) on the same pool means quick-upload of 12+ files will throwRejectedExecutionException. The upload HTTP response succeeds, thumbnails silently don't generate, backfill eventually picks them up — but in production this surfaces as log spam and missing thumbnails users complain about. Not a blocker, but it shifts work onto the backfill path.byte[]+ PDFBox PDDocument (~2–3× the file size during parse) + aBufferedImageat page resolution (~50MB for an A4 at 300dpi) is easily 300MB peak per concurrent thumbnail task. Two concurrent tasks on the shared pool = 600MB on top of baseline + OCR. Tight, not impossible.log.warn. When a user says "no thumbnail appeared", the operator has to grep application logs by document UUID. Worth a one-lineMetrics.counter("thumbnails.generated", "result", ...).increment()if we have Micrometer wired, or a structured log tag at minimum.docker-compose.ymlis unchanged by this plan. Confirmed — no new services, no new bucket, no new env vars. That's the right outcome. Production migration to Hetzner Object Storage (separate effort) works identically: thethumbnails/prefix is a plain S3 key.Recommendations
Dedicated
thumbnailExecutorbean withCallerRunsPolicy. Put it next toauditExecutorinAsyncConfig.java:Annotate
@Async("thumbnailExecutor"). Same pattern as@Async("auditExecutor"). Isolates PDF rendering from OCR.Stream PDF bytes instead of fully-buffering.
FileService.downloadFileBytesis ~50MB for a full PDF; switch todownloadFileStream(s3Key)returning theResponseInputStream. PDFBox can parse fromInputStream, PDFRenderer renders one page — you never need all pages in memory. Halves the memory ceiling.Log failures structured, not just
log.warn. For the backfill especially, an operator running it against 5000 docs wants a single summary line:And for each failure:
log.warn("Thumbnail generation failed for doc {}: {}", docId, e.getMessage()). The message goes to Loki once we set that up.CI test cost. The Playwright test Sara's requesting + the Testcontainers integration tests add ~20–40s to the pipeline. Acceptable — we're at ~4 min total today. Real MinIO in the integration test (vs. mock) is worth the extra 5–10s because
PutObjectRequestsigning differs subtly between mock and real S3 and has bitten us before.Open Decisions
🎨 Leonie Voss — UX & Accessibility Lead
The spec at
docs/specs/briefwechsel-thumbnail-rows-spec.htmlis the source of truth for the visual — this plan describes the plumbing, not the final presentation. A few things the plan should pin down before implementation.Observations
object-fit: coverand centered focal point) so rows stay regular.$lib/components/DocumentRow.sveltebut notPersonDocumentList's current icon tile. The current PDF icon there has brand-navy background. Thumbnails will look very different next to it — inconsistent. Apply the same tile treatment everywhere thumbnails appear: white background, 1pxborder-line, 2px radius, fixed dimensions.thumbnailKeyis null. Three cases produce this: (a) pre-backfill existing docs, (b) unsupported content type (.doc,.eml), (c) transient generation failure. All three should render the current PDF icon as graceful degradation — users should never see a broken<img>or an empty rectangle.bg-surface/text-inksemantic tokens; the existing light-on-navy thumbnail placeholder works in both modes. In dark mode, JPEG thumbnails will show a bright white paper background against a dark page — distracting. This is a spec-level decision (borderize them? slight opacity reduction?) —docs/specs/briefwechsel-thumbnail-rows-spec.htmlshould already cover it./admin/systemgoes throughm.admin_system_...()keys. New keys needed inmessages/{de,en,es}.json.Recommendations
Generate at 240×auto, not 400×auto. Quality 85. Write as
image/jpeg. Keep PNGs/TIFFs as JPEG output too — a 240×auto JPEG of a scanned letter looks identical to PNG at 2–3× the size.Fixed tile dimensions in the UI — not "height proportional":
object-coverwithobject-topshows the top of the page (where letter salutations and titles live) in a consistent 60×84 frame. No layout shift between PDFs and images.Use
alt=""on thumbnail<img>. The thumbnail is decorative; the document title next to it is the accessible name. Puttingalt="Dokument-Vorschau"on every one adds announcement noise for screen readers. Empty alt + decorative is the right WCAG call here.loading="lazy" decoding="async"on every thumbnail. Avoids blocking the main thread on first paint.Focus-visible ring on the enclosing link. The existing DocumentRow
<a>gets focus, not the img. Confirm it has a visible focus indicator in both light and dark mode — the row's existing<a>wrapper is the focus target. Run axe-core.Paraglide keys — add these to
messages/{de,en,es}.json:admin_system_thumbnails_heading→ "Thumbnails erzeugen" / "Generate thumbnails" / "Generar miniaturas"admin_system_thumbnails_descriptionadmin_system_thumbnails_btn_start,_btn_retry,_status_running,_status_done,_status_failedMirror the existing
admin_system_import_*keys exactly.Open Decisions
object-topvsobject-center. Top gives you the salutation / masthead / title. Center is better for photos and landscape images. I leanobject-topfor letter-heavy archive, but I want to eyeball a few real examples from/importbefore locking it in.mix-blend-mode: multiplyor a 92% opacity on the img in dark mode.🗳️ Decision Queue — Action Required
4 decisions need your input before implementation starts. Deduplicated across personas.
🎨 UX & Presentation
Thumbnail generation width. Leonie recommends 240px (display is 60×84 CSS → 120×168 retina). Tobias backs the smaller number for bandwidth (240px ≈ 6–8KB/thumbnail vs 15–25KB at 400px; for a 30-row list that's ~240KB vs ~750KB on mobile). Plan currently says 400px.
Options: 240px (default recommendation) · 320px (middle ground) · 400px (as planned).
(Raised by: Leonie, Tobias, Felix)
object-topvsobject-centerfocal point. A fixed 60×84 tile needs a cropping rule. Letter-heavy docs benefit fromobject-top(salutation + title visible). Photo-heavy content benefits fromobject-center. Leonie leans top but wants to eyeball real examples from/importfirst.Options: top (letter-first) · center (photo-first) · both, with content-type hint driving choice.
(Raised by: Leonie)
Dark-mode thumbnail treatment. JPEG thumbnails of white letter scans will look very bright against a dark page. Leonie flagged but didn't lock in a choice.
Options: no treatment (accept the brightness) ·
mix-blend-mode: multiplyin dark mode · 92% opacity on<img>in dark mode · thinborder-lineframe to separate from page background.(Raised by: Leonie)
🏛️ Architecture
docs/adr/) capturing why thumbnails render in-process rather than being delegated to the existing Python OCR service. This is not an open technical choice (the plan is correct to keep it in the backend for now) — it's a deliverable to prevent a future reviewer from second-guessing the decision.Options: write the ADR as part of this PR · defer to a follow-up · skip.
(Raised by: Markus)
✅ Items NOT in the queue — convergent recommendations already aligned across personas
The following were flagged by multiple personas but have a single clear answer:
afterCommit, not inlineAuditService.logAfterCommitpatternthumbnailExecutorbean, not sharedtaskExecutorcore=1, max=2, queue=200, CallerRunsPolicyin AsyncConfigFileService.downloadFileStream()Cache-Control: privatenotpublictwelvemonkeys-imageio-tiffdependencyGraphics2D.drawImage+ bilinear, notImage.getScaledInstancealt=""on thumbnail<img>(decorative)loading="lazy" decoding="async"on every thumbnailadmin_system_import_*object-cover— not variable heightIf you disagree with any of the above, flag it in the walk-through and we'll reopen.
🎯 Discussion Resolutions
After walking through every point raised by the six personas, here are the agreed decisions. These now act as the authoritative design for implementation —
implementwill read this comment alongside the original issue.T1 — Async dispatch & transaction ordering
ThumbnailAsyncRunner.dispatchAfterCommit(UUID docId)encapsulatesTransactionSynchronizationManager.registerSynchronization(…afterCommit…). All callers (4 sites inDocumentService, 1 inMassImportService) simply call this method.generateAsyncfires before the commit. Mirror the existingAuditService.logAfterCommitpattern — transaction awareness belongs in the runner, not at every callsite.T2 — Thread pool & backpressure
@Bean("thumbnailExecutor")inAsyncConfig.java:core=1, max=2, queue=200, CallerRunsPolicy, prefixThumbnail-.@Async("thumbnailExecutor")on runner and backfill service. Backfill loop strictly sequential.taskExecutor(queue=10, AbortPolicy) and silently drop thumbnails.CallerRunsPolicyprovides backpressure instead of dropping work. Dedicated pool isolates from OCR.T3 — Memory ceiling & PDF handling
FileService.downloadFileStream(s3Key)returningResponseInputStream<GetObjectResponse>.ThumbnailServiceconsumes stream, never buffers full bytes.generate()call insideThumbnailAsyncRunner. On timeout: warn log, incrementfailedcounter, don't rethrow.Graphics2D.drawImagewithVALUE_INTERPOLATION_BILINEAR. Do not useImage.getScaledInstance.com.twelvemonkeys.imageio:imageio-tiff:3.12.0tobackend/pom.xml.T4 — Thumbnail endpoint: caching, headers, permissions
Cache-Control: private, max-age=31536000, immutableon/api/documents/{id}/thumbnail. Regression test assertsprivatepresent andpublicabsent.image/jpegset on S3putObject, echoed back via existingFileService.downloadFile().@RequirePermission— mirrors existing/fileendpoint (authenticated-only). Future ACL work addresses both endpoints together.public; consistency with/file.T5 — Output size & format
T6 — Tile rendering in UI
$lib/components/DocumentThumbnail.sveltetakes a singledoc: Pick<Document, 'id' | 'thumbnailKey' | 'thumbnailGeneratedAt' | 'contentType'>prop. Used inDocumentRow.svelteandPersonDocumentList.svelte.object-fit: cover,object-position: top, white bg,border-line,rounded-sm,alt="",loading="lazy",decoding="async". Focus-visible ring stays on the enclosing<a>.object-topglobally — optimized for letter-heavy archive.dark:mix-blend-multiplyon the<img>; border visible in both themes.thumbnailKeyis null.T7 — Admin card i18n
admin_system_thumbnails_heading,_description,_btn_start,_btn_retry,_status_running,_status_done {count},_status_failed {message}), mirroringadmin_system_import_*. Translations drafted in de/en/es.T8 — Cache-bust URL parameter
thumbnailGeneratedAt(LocalDateTime) as-is. Accept the escaped-colons URL. No separatethumbnailVersionfield.@Transientgetter off the entity is worth the minor aesthetic hit.T9 — Test coverage
@WebMvcTest) + frontend unit + component tests + one Playwright E2E on admin card + axe-core scan.docker-compose.ci.yml. Everything else mocks atFileServiceboundary.src/test/resources/fixtures/sample.pdf(generated by one-shot PDFBox script) andsample.png.T10 — Observability
log.warn("Thumbnail generation failed for doc={} reason={}", ...). Backfill endlog.info("Thumbnail backfill complete: total={} processed={} skipped={} failed={} durationMs={}", ...).T11 — Documentation
docs/adr/ADR-001-pdfbox-for-thumbnails.mdas part of this PR. Establishes thedocs/adr/convention.ConversationTimeline/ChronikRow— explicitly out of scope for this iteration.Open / Skipped
None — every point raised by the personas was walked through and resolved. Nothing deferred.