From a58e796ffae341a0f909a6fc89722a28c43f9e6f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 19:04:04 +0200 Subject: [PATCH] feat(dashboard): add isReader flag + reader branch to page load Read-only users (no WRITE_ALL or ANNOTATE_ALL) now receive lean reader data (stats, top-4 persons, 5 recent docs, 3 recent stories, and drafts when BLOG_WRITE) instead of the contributor transcription queues. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.server.ts | 81 ++++++++++- frontend/src/routes/page.server.spec.ts | 177 ++++++++++++++++++++++-- 2 files changed, 247 insertions(+), 11 deletions(-) diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index ae356311..9643fa85 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -9,8 +9,13 @@ type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; 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 Geschichte = components['schemas']['Geschichte']; -export async function load({ fetch }) { +export async function load({ fetch, parent }) { + const { canWrite, canAnnotate, canBlogWrite } = await parent(); + const isReader = !canWrite && !canAnnotate; const api = createApiClient(fetch); try { @@ -20,6 +25,73 @@ export async function load({ fetch }) { throw redirect(302, '/login'); } + if (isReader) { + const readerFetches: Promise[] = [ + api.GET('/api/stats'), + api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }), + api.GET('/api/documents', { + params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } } + }), + api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }) + ]; + if (canBlogWrite) { + readerFetches.push( + api.GET('/api/geschichten', { params: { query: { status: 'DRAFT', limit: 10 } } }) + ); + } + + const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] = + await Promise.allSettled(readerFetches); + + let readerStats: StatsDTO | null = null; + let topPersons: PersonSummaryDTO[] = []; + let recentDocs: Document[] = []; + let recentStories: Geschichte[] = []; + let drafts: Geschichte[] = []; + + if ( + statsRes?.status === 'fulfilled' && + (statsRes.value as { response: Response }).response.ok + ) { + readerStats = ((statsRes.value as { data: unknown }).data as StatsDTO) ?? null; + } + if ( + topPersonsRes?.status === 'fulfilled' && + (topPersonsRes.value as { response: Response }).response.ok + ) { + topPersons = ((topPersonsRes.value as { data: unknown }).data as PersonSummaryDTO[]) ?? []; + } + if ( + recentDocsRes?.status === 'fulfilled' && + (recentDocsRes.value as { response: Response }).response.ok + ) { + recentDocs = ((recentDocsRes.value as { data: unknown }).data as Document[]) ?? []; + } + if ( + recentStoriesRes?.status === 'fulfilled' && + (recentStoriesRes.value as { response: Response }).response.ok + ) { + recentStories = ((recentStoriesRes.value as { data: unknown }).data as Geschichte[]) ?? []; + } + if ( + draftsRes?.status === 'fulfilled' && + (draftsRes.value as { response: Response }).response.ok + ) { + drafts = ((draftsRes.value as { data: unknown }).data as Geschichte[]) ?? []; + } + + return { + isReader: true as const, + canBlogWrite, + readerStats, + topPersons, + recentDocs, + recentStories, + drafts, + error: null as string | null + }; + } + const [ statsResult, resumeResult, @@ -87,6 +159,7 @@ export async function load({ fetch }) { } return { + isReader: false as const, stats, resumeDoc, pulse, @@ -103,6 +176,7 @@ export async function load({ fetch }) { if ((e as { status?: number }).status) throw e; console.error('Error loading data:', e); return { + isReader, stats: null, resumeDoc: null, pulse: null, @@ -113,6 +187,11 @@ export async function load({ fetch }) { weeklyStats: null, incompleteDocs: [] as IncompleteDocumentDTO[], incompleteTotal: 0, + readerStats: null, + topPersons: [] as PersonSummaryDTO[], + recentDocs: [] as Document[], + 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 8832bde2..6603d6c8 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -19,6 +19,10 @@ function makeUrl(params: Record = {}) { return url; } +function contributorParent() { + return vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false }); +} + // ─── always-dashboard behaviour ─────────────────────────────────────────────── it('never calls /api/documents/search regardless of URL params', async () => { @@ -29,8 +33,9 @@ it('never calls /api/documents/search regardless of URL params', async () => { await load({ url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), - fetch: vi.fn() as unknown as typeof fetch - }); + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); expect(calledEndpoints).not.toContain('/api/documents/search'); @@ -42,7 +47,11 @@ it('always fetches dashboard data regardless of URL params', async () => { typeof createApiClient >); - await load({ url: makeUrl({ q: 'Urlaub' }), fetch: vi.fn() as unknown as typeof fetch }); + await load({ + url: makeUrl({ q: 'Urlaub' }), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); expect(calledEndpoints).toContain('/api/stats'); @@ -99,7 +108,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 }); expect(result.resumeDoc).not.toBeNull(); @@ -132,7 +145,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats?.totalDocuments).toBe(248); expect(result.stats?.totalPersons).toBe(34); @@ -149,7 +166,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats).toBeNull(); }); @@ -166,7 +187,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.resumeDoc).toBeNull(); }); @@ -186,7 +211,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.activityFeed).toEqual([]); }); @@ -201,7 +230,11 @@ describe('home page load — auth redirect', () => { } as ReturnType); await expect( - load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]) ).rejects.toMatchObject({ location: '/login' }); }); }); @@ -214,8 +247,132 @@ describe('home page load — network error fallback', () => { GET: vi.fn().mockRejectedValue(new Error('Network failure')) } as ReturnType); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.error).toBe('Daten konnten nicht geladen werden.'); }); }); + +// ─── reader branch ───────────────────────────────────────────────────────────── + +describe('home page load — reader branch (isReader = !canWrite && !canAnnotate)', () => { + it('does not call /api/transcription/* endpoints for a read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string); + const transcriptionCalls = calledEndpoints.filter((ep: string) => + ep.startsWith('/api/transcription') + ); + expect(transcriptionCalls).toHaveLength(0); + }); + + it('calls /api/stats, /api/persons, /api/documents, /api/geschichten for a read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string); + expect(calledEndpoints).toContain('/api/stats'); + expect(calledEndpoints).toContain('/api/persons'); + expect(calledEndpoints).toContain('/api/documents'); + expect(calledEndpoints).toContain('/api/geschichten'); + }); + + it('does not call /api/geschichten with status=DRAFT when canBlogWrite is false', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const draftCalls = mockGet.mock.calls.filter( + (c: unknown[]) => + c[0] === '/api/geschichten' && + (c[1] as { params?: { query?: { status?: string } } })?.params?.query?.status === 'DRAFT' + ); + expect(draftCalls).toHaveLength(0); + }); + + it('calls /api/geschichten with status=DRAFT when canBlogWrite is true', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true }) + } as Parameters[0]); + + const draftCalls = mockGet.mock.calls.filter( + (c: unknown[]) => + c[0] === '/api/geschichten' && + (c[1] as { params?: { query?: { status?: string } } })?.params?.query?.status === 'DRAFT' + ); + expect(draftCalls).toHaveLength(1); + }); + + it('returns isReader: true for read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + 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); + }); + + it('returns isReader: false for contributor with WRITE_ALL', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(false); + }); +});