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();
|
.toList();
|
||||||
|
|
||||||
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
||||||
totalBlocks, pct, null, collaborators);
|
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DashboardPulseDTO getPulse(UUID userId) {
|
public DashboardPulseDTO getPulse(UUID userId) {
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -131,4 +134,19 @@ public class Document {
|
|||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
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 org.raddatz.familienarchiv.service.UserService;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -45,6 +46,31 @@ class DashboardServiceTest {
|
|||||||
|
|
||||||
@InjectMocks DashboardService dashboardService;
|
@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) ─────────────────────────────
|
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
|
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
|
||||||
<svg
|
<div
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||||
width="180"
|
|
||||||
height="246"
|
|
||||||
viewBox="0 0 180 246"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
>
|
||||||
<defs>
|
{#if resumeDoc.thumbnailUrl}
|
||||||
<linearGradient id="parchment" x1="0" y1="0" x2="0" y2="1">
|
<img
|
||||||
<stop offset="0%" stop-color="#f5f0e8" />
|
data-testid="resume-thumbnail-img"
|
||||||
<stop offset="100%" stop-color="#ede8d5" />
|
src={resumeDoc.thumbnailUrl}
|
||||||
</linearGradient>
|
alt=""
|
||||||
</defs>
|
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||||
<rect width="180" height="246" fill="url(#parchment)" />
|
loading="lazy"
|
||||||
<line x1="30" y1="40" x2="150" y2="40" stroke="#b0a898" stroke-width="1" />
|
decoding="async"
|
||||||
<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" />
|
{:else}
|
||||||
<line x1="30" y1="130" x2="150" y2="130" stroke="#b0a898" stroke-width="1" />
|
<div
|
||||||
<line x1="30" y1="160" x2="150" y2="160" stroke="#b0a898" stroke-width="1" />
|
data-testid="resume-thumbnail-fallback"
|
||||||
</svg>
|
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">
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
|
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ const mockResume: DashboardResumeDTO = {
|
|||||||
collaborators: []
|
collaborators: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockResumeWithThumbnail: DashboardResumeDTO = {
|
||||||
|
...mockResume,
|
||||||
|
thumbnailUrl: '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00'
|
||||||
|
};
|
||||||
|
|
||||||
describe('DashboardResumeStrip', () => {
|
describe('DashboardResumeStrip', () => {
|
||||||
it('renders empty state heading when resumeDoc is null', async () => {
|
it('renders empty state heading when resumeDoc is null', async () => {
|
||||||
render(DashboardResumeStrip, { resumeDoc: null });
|
render(DashboardResumeStrip, { resumeDoc: null });
|
||||||
@@ -52,4 +57,23 @@ describe('DashboardResumeStrip', () => {
|
|||||||
const label = page.getByText(/4 Abschnitte/i);
|
const label = page.getByText(/4 Abschnitte/i);
|
||||||
await expect.element(label).toBeInTheDocument();
|
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">
|
<script lang="ts">
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { thumbnailUrl } from '$lib/thumbnails';
|
|
||||||
|
|
||||||
type Doc = Pick<
|
type Doc = Pick<components['schemas']['Document'], 'id' | 'thumbnailUrl' | 'contentType'>;
|
||||||
components['schemas']['Document'],
|
|
||||||
'id' | 'thumbnailKey' | 'thumbnailGeneratedAt' | 'contentType'
|
|
||||||
>;
|
|
||||||
|
|
||||||
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
|
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
|
||||||
const url = $derived(thumbnailUrl(doc));
|
const url = $derived(doc.thumbnailUrl ?? null);
|
||||||
|
|
||||||
const containerClass = $derived(
|
const containerClass = $derived(
|
||||||
size === 'lg'
|
size === 'lg'
|
||||||
|
|||||||
@@ -1390,6 +1390,7 @@ export interface components {
|
|||||||
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
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;
|
location?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
thumbnailKey?: string;
|
thumbnailUrl?: string;
|
||||||
thumbnailGeneratedAt?: string;
|
|
||||||
}[];
|
}[];
|
||||||
heading: string;
|
heading: string;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user