As a user I want the dashboard resume strip to show the actual document thumbnail so I recognize what I was working on at a glance #314
@@ -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) {
|
||||
|
||||
@@ -6,8 +6,11 @@ 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;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
@@ -131,4 +134,19 @@ public class Document {
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Builder.Default
|
||||
private Set<TrainingLabel> 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";
|
||||
if (thumbnailGeneratedAt == null) return base;
|
||||
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
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();
|
||||
}
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception {
|
||||
UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333");
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
|
||||
.build();
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
String json = mapper.writeValueAsString(doc);
|
||||
|
||||
// Locks the wire contract, not just the Java API: every Document JSON must carry
|
||||
// `thumbnailUrl`. Protects against silent breakage if the getter gets renamed,
|
||||
// hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the
|
||||
// frontend rendering the fallback icon on every surface.
|
||||
assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\"");
|
||||
}
|
||||
}
|
||||
@@ -44,27 +44,41 @@ function safeColor(color: string): string {
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="180"
|
||||
height="246"
|
||||
viewBox="0 0 180 246"
|
||||
aria-hidden="true"
|
||||
class="shrink-0"
|
||||
<div
|
||||
class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="parchment" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f5f0e8" />
|
||||
<stop offset="100%" stop-color="#ede8d5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="180" height="246" fill="url(#parchment)" />
|
||||
<line x1="30" y1="40" x2="150" y2="40" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="70" x2="150" y2="70" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="100" x2="150" y2="100" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="130" x2="150" y2="130" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="160" x2="150" y2="160" stroke="#b0a898" stroke-width="1" />
|
||||
</svg>
|
||||
{#if resumeDoc.thumbnailUrl}
|
||||
<img
|
||||
data-testid="resume-thumbnail-img"
|
||||
src={resumeDoc.thumbnailUrl}
|
||||
alt=""
|
||||
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
data-testid="resume-thumbnail-fallback"
|
||||
class="flex h-full w-full items-center justify-center text-ink-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-24 w-24"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
|
||||
|
||||
@@ -21,6 +21,11 @@ const mockResume: DashboardResumeDTO = {
|
||||
collaborators: []
|
||||
};
|
||||
|
||||
const mockResumeWithThumbnail: DashboardResumeDTO = {
|
||||
...mockResume,
|
||||
thumbnailUrl: '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00'
|
||||
};
|
||||
|
||||
describe('DashboardResumeStrip', () => {
|
||||
it('renders empty state heading when resumeDoc is null', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: null });
|
||||
@@ -52,4 +57,23 @@ describe('DashboardResumeStrip', () => {
|
||||
const label = page.getByText(/4 Abschnitte/i);
|
||||
await expect.element(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders thumbnail img with expected attrs when thumbnailUrl is set', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResumeWithThumbnail });
|
||||
const img = page.getByTestId('resume-thumbnail-img');
|
||||
await expect.element(img).toBeInTheDocument();
|
||||
await expect
|
||||
.element(img)
|
||||
.toHaveAttribute('src', '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00');
|
||||
await expect.element(img).toHaveAttribute('alt', '');
|
||||
await expect.element(img).toHaveAttribute('loading', 'lazy');
|
||||
await expect.element(img).toHaveAttribute('decoding', 'async');
|
||||
});
|
||||
|
||||
it('renders fallback icon when thumbnailUrl is null', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResume });
|
||||
const fallback = page.getByTestId('resume-thumbnail-fallback');
|
||||
await expect.element(fallback).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('resume-thumbnail-img')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<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'
|
||||
>;
|
||||
type Doc = Pick<components['schemas']['Document'], 'id' | 'thumbnailUrl' | 'contentType'>;
|
||||
|
||||
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
|
||||
const url = $derived(thumbnailUrl(doc));
|
||||
const url = $derived(doc.thumbnailUrl ?? null);
|
||||
|
||||
const containerClass = $derived(
|
||||
size === 'lg'
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -20,8 +20,7 @@ let {
|
||||
location?: string | null;
|
||||
status: string;
|
||||
contentType?: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailUrl?: string;
|
||||
}[];
|
||||
heading: string;
|
||||
emptyMessage: string;
|
||||
|
||||
Reference in New Issue
Block a user