fix(reader-dashboard): 500 crash for READ_ALL users — recentDocs always undefined #661

Merged
marcel merged 4 commits from worktree-fix+reader-dashboard-doc-undefined into main 2026-05-25 17:54:42 +02:00
8 changed files with 69 additions and 13 deletions
Showing only changes of commit a1035171c2 - Show all commits

View File

@@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag; import org.raddatz.familienarchiv.tag.Tag;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -32,5 +33,9 @@ public record DocumentListItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors, List<ActivityActorDTO> contributors,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime updatedAt
) {} ) {}

View File

@@ -767,7 +767,9 @@ public class DocumentService {
doc.getSummary(), doc.getSummary(),
completionPct, completionPct,
contributors, contributors,
match match,
doc.getCreatedAt(),
doc.getUpdatedAt()
); );
} }

View File

@@ -135,7 +135,7 @@ class DocumentControllerTest {
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null, docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, 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")) mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -153,7 +153,7 @@ class DocumentControllerTest {
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null, docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, 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")) mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@@ -16,7 +16,7 @@ class DocumentSearchResultTest {
return new DocumentListItem( return new DocumentListItem(
docId, "Test", "test.pdf", null, null, null, docId, "Test", "test.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
0, List.of(), SearchMatchData.empty()); 0, List.of(), SearchMatchData.empty(), null, null);
} }
@Test @Test
@@ -66,7 +66,7 @@ class DocumentSearchResultTest {
DocumentListItem item = new DocumentListItem( DocumentListItem item = new DocumentListItem(
id, "T", "t.pdf", null, null, null, id, "T", "t.pdf", null, null, null,
List.of(), List.of(), null, 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)); DocumentSearchResult result = DocumentSearchResult.of(List.of(item));

View File

@@ -2407,6 +2407,10 @@ export interface components {
completionPercentage: number; completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][]; contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"]; matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
}; };
DocumentSearchResult: { DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][]; items: components["schemas"]["DocumentListItem"][];

View File

@@ -3,15 +3,15 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime'; import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Document = components['schemas']['Document']; type DocumentListItem = components['schemas']['DocumentListItem'];
interface Props { interface Props {
documents: Document[]; documents: DocumentListItem[];
} }
const { documents }: Props = $props(); 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(); return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
} }
</script> </script>

View File

@@ -10,7 +10,7 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
type Document = components['schemas']['Document']; type DocumentListItem = components['schemas']['DocumentListItem'];
type Geschichte = components['schemas']['Geschichte']; type Geschichte = components['schemas']['Geschichte'];
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null { function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
@@ -53,8 +53,8 @@ export async function load({ fetch, parent }) {
const readerStats = settled<StatsDTO>(statsRes); const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? []; const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
const recentDocs = searchData?.items.map((i) => i.document) ?? []; const recentDocs = searchData?.items ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? []; const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
const drafts = settled<Geschichte[]>(draftsRes) ?? []; const drafts = settled<Geschichte[]>(draftsRes) ?? [];
@@ -167,7 +167,7 @@ export async function load({ fetch, parent }) {
incompleteTotal: 0, incompleteTotal: 0,
readerStats: null, readerStats: null,
topPersons: [] as PersonSummaryDTO[], topPersons: [] as PersonSummaryDTO[],
recentDocs: [] as Document[], recentDocs: [] as DocumentListItem[],
recentStories: [] as Geschichte[], recentStories: [] as Geschichte[],
drafts: [] as Geschichte[], drafts: [] as Geschichte[],
error: 'Daten konnten nicht geladen werden.' as string | null error: 'Daten konnten nicht geladen werden.' as string | null

View File

@@ -394,6 +394,51 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
expect(result.isReader).toBe(false); 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<typeof load>[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 () => { it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => {
const okStats = { const okStats = {
response: { ok: true, status: 200 }, response: { ok: true, status: 200 },