fix(reader-dashboard): recentDocs items were always undefined for READ_ALL users
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ActivityActorDTO> contributors,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
SearchMatchData matchData
|
||||
SearchMatchData matchData,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime createdAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime updatedAt
|
||||
) {}
|
||||
|
||||
@@ -767,7 +767,9 @@ public class DocumentService {
|
||||
doc.getSummary(),
|
||||
completionPct,
|
||||
contributors,
|
||||
match
|
||||
match,
|
||||
doc.getCreatedAt(),
|
||||
doc.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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"][];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
|
||||
@@ -53,8 +53,8 @@ export async function load({ fetch, parent }) {
|
||||
|
||||
const readerStats = settled<StatsDTO>(statsRes);
|
||||
const topPersons = settled<PersonSummaryDTO[]>(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<Geschichte[]>(recentStoriesRes) ?? [];
|
||||
const drafts = settled<Geschichte[]>(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
|
||||
|
||||
@@ -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<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 () => {
|
||||
const okStats = {
|
||||
response: { ok: true, status: 200 },
|
||||
|
||||
Reference in New Issue
Block a user