From 096f66eb156fc441b60cfa3ca8658745bc89fa3f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 21:45:33 +0200 Subject: [PATCH 01/12] test(document): getThumbnailUrl returns null when thumbnailKey is null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First TDD step for centralising the thumbnail URL convention on the Document entity (#309). Adds a stub getter returning null and a test that locks the "no key → no URL" branch. Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/model/Document.java | 4 ++++ .../familienarchiv/model/DocumentTest.java | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index dc66fb5d..627a1c6b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -131,4 +131,8 @@ public class Document { @Enumerated(EnumType.STRING) @Builder.Default private Set trainingLabels = new HashSet<>(); + + public String getThumbnailUrl() { + return null; + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java new file mode 100644 index 00000000..c6e82fb3 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java @@ -0,0 +1,23 @@ +package org.raddatz.familienarchiv.model; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DocumentTest { + + @Test + void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() { + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Brief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailKey(null) + .build(); + + assertThat(doc.getThumbnailUrl()).isNull(); + } +} -- 2.49.1 From df260d5c644bec29b3419944233384bff6007207 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 21:52:09 +0200 Subject: [PATCH 02/12] feat(document): getThumbnailUrl composes /api/documents/{id}/thumbnail when key present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-cache-buster branch covers documents whose thumbnail key is set but whose thumbnailGeneratedAt is still null — which only happens in the narrow window between the key being persisted and the async worker stamping the timestamp (#309). Co-Authored-By: Claude Opus 4.7 --- .../raddatz/familienarchiv/model/Document.java | 3 ++- .../familienarchiv/model/DocumentTest.java | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 627a1c6b..432fd6b0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -133,6 +133,7 @@ public class Document { private Set trainingLabels = new HashSet<>(); public String getThumbnailUrl() { - return null; + if (thumbnailKey == null) return null; + return "/api/documents/" + id + "/thumbnail"; } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java index c6e82fb3..29071779 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java @@ -20,4 +20,20 @@ class DocumentTest { assertThat(doc.getThumbnailUrl()).isNull(); } + + @Test + void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() { + UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555"); + Document doc = Document.builder() + .id(id) + .title("Brief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailKey("thumbnails/" + id + ".jpg") + .thumbnailGeneratedAt(null) + .build(); + + assertThat(doc.getThumbnailUrl()) + .isEqualTo("/api/documents/" + id + "/thumbnail"); + } } -- 2.49.1 From 9862a51ac7dfd7f4b31c386c21e1ed6c44499d7b Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 21:55:55 +0200 Subject: [PATCH 03/12] feat(document): getThumbnailUrl appends URL-encoded timestamp as cache-buster Matches the shape the frontend previously built via encodeURIComponent(thumbnailGeneratedAt), so the backend is now the single source of truth for the thumbnail URL convention (#309). Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/model/Document.java | 6 ++++- .../familienarchiv/model/DocumentTest.java | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 432fd6b0..8f1690dc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -8,6 +8,8 @@ import org.hibernate.annotations.UpdateTimestamp; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.swagger.v3.oas.annotations.media.Schema; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.HashSet; @@ -134,6 +136,8 @@ public class Document { public String getThumbnailUrl() { if (thumbnailKey == null) return null; - return "/api/documents/" + id + "/thumbnail"; + String base = "/api/documents/" + id + "/thumbnail"; + if (thumbnailGeneratedAt == null) return base; + return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java index 29071779..8b1f9c59 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/DocumentTest.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.model; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -36,4 +37,25 @@ class DocumentTest { assertThat(doc.getThumbnailUrl()) .isEqualTo("/api/documents/" + id + "/thumbnail"); } + + @Test + void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() { + UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45); + Document doc = Document.builder() + .id(id) + .title("Brief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailKey("thumbnails/" + id + ".jpg") + .thumbnailGeneratedAt(generatedAt) + .build(); + + // frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}` + // where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises. + // LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent + // turns ":" into "%3A" but leaves "T" and digits alone. + String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45"; + assertThat(doc.getThumbnailUrl()).isEqualTo(expected); + } } -- 2.49.1 From ad999c47ea3502371eef962620d7cf352bce853e Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 22:02:05 +0200 Subject: [PATCH 04/12] feat(document): expose thumbnailUrl to JSON serialisation @JsonProperty makes the computed getter part of every Document response Jackson produces, so any DTO returning a Document automatically carries the thumbnail URL without per-controller plumbing. The accompanying comment warns future readers that the cache-buster is load-bearing for the endpoint's `immutable` cache header (CWE-525) (#309). Co-Authored-By: Claude Opus 4.7 --- .../java/org/raddatz/familienarchiv/model/Document.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 8f1690dc..e5526294 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -6,6 +6,7 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URLEncoder; @@ -134,6 +135,14 @@ public class Document { @Builder.Default private Set trainingLabels = new HashSet<>(); + // The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail + // endpoint sends `Cache-Control: private, max-age=31536000, immutable` + // (DocumentController.getDocumentThumbnail). `immutable` is only safe because + // this URL changes whenever the underlying file does. Dropping the query param + // would let browsers serve a stale thumbnail for a year after the file is + // replaced, and shared caches could leak one user's thumbnail to another + // (CWE-525). + @JsonProperty("thumbnailUrl") public String getThumbnailUrl() { if (thumbnailKey == null) return null; String base = "/api/documents/" + id + "/thumbnail"; -- 2.49.1 From 510ab1d2d5bfd01a09c8d4c0e93cf7ad03a24001 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 22:07:58 +0200 Subject: [PATCH 05/12] feat(dashboard): populate resume thumbnailUrl from Document DashboardService now reads the URL from the Document's computed getter instead of passing null, so the resume strip can display the real thumbnail of whatever the user was last working on (#309). Co-Authored-By: Claude Opus 4.7 --- .../dashboard/DashboardService.java | 2 +- .../dashboard/DashboardServiceTest.java | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java index 054ea91b..0e5203b2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -82,7 +82,7 @@ public class DashboardService { .toList(); return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt, - totalBlocks, pct, null, collaborators); + totalBlocks, pct, doc.getThumbnailUrl(), collaborators); } public DashboardPulseDTO getPulse(UUID userId) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java index 19a20e51..d9df35cc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.service.TranscriptionService; import org.raddatz.familienarchiv.service.UserService; import java.time.Instant; +import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.util.HashSet; import java.util.List; @@ -45,6 +46,31 @@ class DashboardServiceTest { @InjectMocks DashboardService dashboardService; + // ─── getResume wires thumbnailUrl from Document ─────────────────────────── + + @Test + void getResume_populatesThumbnailUrl_fromDocument() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab"); + + Document doc = Document.builder() + .id(docId).title("Brief").originalFilename("brief.pdf") + .thumbnailKey("thumbnails/" + docId + ".jpg") + .thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0)) + .receivers(new HashSet<>()) + .build(); + + when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId)); + when(documentService.getDocumentById(docId)).thenReturn(doc); + when(transcriptionService.listBlocks(docId)).thenReturn(List.of()); + + DashboardResumeDTO result = dashboardService.getResume(userId); + + assertThat(result).isNotNull(); + assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl()); + assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v="); + } + // ─── toActorDTO (via getResume collaborators) ───────────────────────────── @Test -- 2.49.1 From a8b9133b800a7ac7670dbe39cd63d222a8819e4a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 22:10:32 +0200 Subject: [PATCH 06/12] chore(api): regenerate Document type with thumbnailUrl field Reflects the new @JsonProperty getter on Document. Kept as a minimal manual edit rather than a full regen because the running dev backend belongs to the main workspace and swapping JARs there would be a side effect on a parallel worktree's state. `npm run generate:api` will converge on the same shape. Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/generated/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 351e1597..a72acd15 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1390,6 +1390,7 @@ export interface components { thumbnailAspect?: "PORTRAIT" | "LANDSCAPE"; /** Format: int32 */ pageCount?: number; + thumbnailUrl?: string; originalFilename: string; /** @enum {string} */ status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; -- 2.49.1 From 817749889a6d4345540c84568c7a5ad202332eec Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 22:18:07 +0200 Subject: [PATCH 07/12] refactor(document-thumbnail): read doc.thumbnailUrl instead of composing locally The backend now exposes thumbnailUrl as a serialised computed property on Document, so the component drops its dependency on the frontend URL-builder. PersonDocumentList's inline Doc prop type follows the same shift (#309). Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/components/DocumentThumbnail.svelte | 8 ++------ .../src/routes/persons/[id]/PersonDocumentList.svelte | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/DocumentThumbnail.svelte b/frontend/src/lib/components/DocumentThumbnail.svelte index ee6023e2..a77b1ce1 100644 --- a/frontend/src/lib/components/DocumentThumbnail.svelte +++ b/frontend/src/lib/components/DocumentThumbnail.svelte @@ -1,14 +1,10 @@