From a1035171c2c185c70a2957836a60fa4587bcf4bb Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 14:31:55 +0200 Subject: [PATCH] fix(reader-dashboard): recentDocs items were always undefined for READ_ALL users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server mapped DocumentSearchResult items as { document: Document }[] but the API returns flat DocumentListItem[] — so i.document was always undefined, crashing the reader homepage with a 500. Fix the type + mapping in +page.server.ts, add createdAt/updatedAt to DocumentListItem (needed by ReaderRecentDocs for relative-time display), and update the component to accept DocumentListItem instead of Document. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentListItem.java | 7 ++- .../document/DocumentService.java | 4 +- .../document/DocumentControllerTest.java | 4 +- .../document/DocumentSearchResultTest.java | 4 +- frontend/src/lib/generated/api.ts | 4 ++ .../shared/dashboard/ReaderRecentDocs.svelte | 6 +-- frontend/src/routes/+page.server.ts | 8 ++-- frontend/src/routes/page.server.spec.ts | 45 +++++++++++++++++++ 8 files changed, 69 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java index 7cbc6496..bf8c19a3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java @@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.tag.Tag; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -32,5 +33,9 @@ public record DocumentListItem( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List contributors, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - SearchMatchData matchData + SearchMatchData matchData, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime createdAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime updatedAt ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index cfbbf848..65bd7cd7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -767,7 +767,9 @@ public class DocumentService { doc.getSummary(), completionPct, contributors, - match + match, + doc.getCreatedAt(), + doc.getUpdatedAt() ); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index f7c24541..359211ba 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -135,7 +135,7 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData)))); + 0, List.of(), matchData, null, null)))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) .andExpect(status().isOk()) @@ -153,7 +153,7 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData)))); + 0, List.of(), matchData, null, null)))); mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index 1dd09fed..9effa3bc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -16,7 +16,7 @@ class DocumentSearchResultTest { return new DocumentListItem( docId, "Test", "test.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), SearchMatchData.empty()); + 0, List.of(), SearchMatchData.empty(), null, null); } @Test @@ -66,7 +66,7 @@ class DocumentSearchResultTest { DocumentListItem item = new DocumentListItem( id, "T", "t.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 75, List.of(actor), SearchMatchData.empty()); + 75, List.of(actor), SearchMatchData.empty(), null, null); DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9a9a5408..c2888d97 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2407,6 +2407,10 @@ export interface components { completionPercentage: number; contributors: components["schemas"]["ActivityActorDTO"][]; matchData: components["schemas"]["SearchMatchData"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; }; DocumentSearchResult: { items: components["schemas"]["DocumentListItem"][]; diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 9f54d6b5..5721accc 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -3,15 +3,15 @@ import * as m from '$lib/paraglide/messages.js'; import { relativeTimeDe } from '$lib/shared/relativeTime'; import type { components } from '$lib/generated/api'; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; interface Props { - documents: Document[]; + documents: DocumentListItem[]; } const { documents }: Props = $props(); -function isNew(doc: Document): boolean { +function isNew(doc: DocumentListItem): boolean { return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime(); } diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index b217452f..ed229042 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -10,7 +10,7 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; type Geschichte = components['schemas']['Geschichte']; function settled(res: PromiseSettledResult | undefined): T | null { @@ -53,8 +53,8 @@ export async function load({ fetch, parent }) { const readerStats = settled(statsRes); const topPersons = settled(topPersonsRes) ?? []; - const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); - const recentDocs = searchData?.items.map((i) => i.document) ?? []; + const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); + const recentDocs = searchData?.items ?? []; const recentStories = settled(recentStoriesRes) ?? []; const drafts = settled(draftsRes) ?? []; @@ -167,7 +167,7 @@ export async function load({ fetch, parent }) { incompleteTotal: 0, readerStats: null, topPersons: [] as PersonSummaryDTO[], - recentDocs: [] as Document[], + recentDocs: [] as DocumentListItem[], recentStories: [] as Geschichte[], drafts: [] as Geschichte[], error: 'Daten konnten nicht geladen werden.' as string | null diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index b87ce9a8..27ce1e30 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -394,6 +394,51 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(result.isReader).toBe(false); }); + it('maps search result items directly to recentDocs without wrapping in a .document property', async () => { + const searchItem = { + id: 'd1', + title: 'Liebesbrief', + originalFilename: 'letter.pdf', + completionPercentage: 80, + receivers: [], + tags: [], + contributors: [], + matchData: { titleOffsets: [], senderMatched: false } + }; + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { totalDocuments: 1, totalPersons: 1 } + }) // stats + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // topPersons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 } + }) // search + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // stories + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + request: new Request('http://localhost/'), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + if (result.isReader) { + expect(result.recentDocs).toHaveLength(1); + expect(result.recentDocs[0]).toBeDefined(); + expect(result.recentDocs[0].id).toBe('d1'); + } + }); + it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { const okStats = { response: { ok: true, status: 200 },