diff --git a/frontend/src/routes/chronik/+page.server.ts b/frontend/src/routes/chronik/+page.server.ts new file mode 100644 index 00000000..83bff5d4 --- /dev/null +++ b/frontend/src/routes/chronik/+page.server.ts @@ -0,0 +1,81 @@ +import { fail } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import type { components } from '$lib/generated/api'; + +type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; +type NotificationDTO = components['schemas']['NotificationDTO']; + +export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare'; + +const VALID_FILTERS: FilterValue[] = [ + 'alle', + 'fuer-dich', + 'hochgeladen', + 'transkription', + 'kommentare' +]; + +function parseFilter(raw: string | null): FilterValue { + if (raw && (VALID_FILTERS as string[]).includes(raw)) return raw as FilterValue; + return 'alle'; +} + +export async function load({ fetch, url }) { + const api = createApiClient(fetch); + const filter = parseFilter(url.searchParams.get('filter')); + const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 40); + + const [activityResult, unreadResult] = await Promise.allSettled([ + api.GET('/api/dashboard/activity', { params: { query: { limit } } }), + api.GET('/api/notifications', { + params: { query: { read: false, page: 0, size: 20 } } + }) + ]); + + let activityFeed: ActivityFeedItemDTO[] = []; + let unreadNotifications: NotificationDTO[] = []; + let loadError: string | null = null; + + if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) { + activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? []; + } else if (activityResult.status === 'fulfilled') { + loadError = 'activity'; + } + + if (unreadResult.status === 'fulfilled' && unreadResult.value.response.ok) { + unreadNotifications = unreadResult.value.data?.content ?? []; + } + + return { + filter, + activityFeed, + unreadNotifications, + loadError + }; +} + +export const actions = { + dismiss: async ({ request, fetch }) => { + const api = createApiClient(fetch); + const formData = await request.formData(); + const id = formData.get('id'); + if (typeof id !== 'string' || id.length === 0) { + return fail(400, { error: 'missing id' }); + } + const result = await api.PATCH('/api/notifications/{id}/read', { + params: { path: { id } } + }); + if (!result.response.ok) { + return fail(result.response.status, { error: 'failed' }); + } + return { success: true }; + }, + 'mark-all': async ({ fetch }) => { + const api = createApiClient(fetch); + const result = await api.POST('/api/notifications/read-all'); + if (!result.response.ok) { + return fail(result.response.status, { error: 'failed' }); + } + return { success: true }; + } +}; diff --git a/frontend/src/routes/chronik/+page.svelte b/frontend/src/routes/chronik/+page.svelte new file mode 100644 index 00000000..24bbc394 --- /dev/null +++ b/frontend/src/routes/chronik/+page.svelte @@ -0,0 +1,208 @@ + + + + {m.chronik_page_title()} + + +
+
+

{m.chronik_page_title()}

+
+ + {#if data.loadError === 'activity'} + + {:else} + + +
+ +
+ + {#if isEmpty} +
+ +
+ {:else} + + +
{announcement}
+ +
+ + + {#if isLoadingMore} + + {/if} +
+ {/if} + {/if} +
diff --git a/frontend/src/routes/chronik/page.server.spec.ts b/frontend/src/routes/chronik/page.server.spec.ts new file mode 100644 index 00000000..7966147c --- /dev/null +++ b/frontend/src/routes/chronik/page.server.spec.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { actions, load } from './+page.server'; + +const mockApi = { + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn() +}; + +vi.mock('$lib/api.server', () => ({ + createApiClient: () => mockApi +})); + +function buildUrl(search = ''): URL { + return new URL(`http://localhost/chronik${search}`); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('chronik/load', () => { + it('requests the activity feed with a 40-item limit', async () => { + mockApi.GET.mockImplementation((path: string) => { + if (path === '/api/dashboard/activity') { + return Promise.resolve({ response: { ok: true }, data: [] }); + } + return Promise.resolve({ response: { ok: true }, data: { content: [] } }); + }); + + await load({ fetch, url: buildUrl() } as never); + + expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { + params: { query: { limit: 40 } } + }); + }); + + it('requests only unread notifications for Für-dich', async () => { + mockApi.GET.mockImplementation((path: string) => { + if (path === '/api/dashboard/activity') { + return Promise.resolve({ response: { ok: true }, data: [] }); + } + return Promise.resolve({ response: { ok: true }, data: { content: [] } }); + }); + + await load({ fetch, url: buildUrl() } as never); + + expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', { + params: { query: { read: false, page: 0, size: 20 } } + }); + }); + + it('returns the activity feed and unread notifications on success', async () => { + const feed = [{ kind: 'FILE_UPLOADED', documentId: 'd1' }]; + const unread = [{ id: 'n1', type: 'MENTION' }]; + mockApi.GET.mockImplementation((path: string) => { + if (path === '/api/dashboard/activity') { + return Promise.resolve({ response: { ok: true }, data: feed }); + } + return Promise.resolve({ response: { ok: true }, data: { content: unread } }); + }); + + const result = await load({ fetch, url: buildUrl() } as never); + + expect(result.activityFeed).toEqual(feed); + expect(result.unreadNotifications).toEqual(unread); + expect(result.filter).toBe('alle'); + expect(result.loadError).toBeNull(); + }); + + it('surfaces "activity" loadError when the dashboard endpoint returns non-ok', async () => { + mockApi.GET.mockImplementation((path: string) => { + if (path === '/api/dashboard/activity') { + return Promise.resolve({ response: { ok: false, status: 500 }, error: {} }); + } + return Promise.resolve({ response: { ok: true }, data: { content: [] } }); + }); + + const result = await load({ fetch, url: buildUrl() } as never); + + expect(result.loadError).toBe('activity'); + expect(result.activityFeed).toEqual([]); + }); + + it('parses the filter query param, falling back to "alle" for invalid values', async () => { + mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] }); + + const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never); + expect(validResult.filter).toBe('fuer-dich'); + + mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] }); + const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never); + expect(invalidResult.filter).toBe('alle'); + }); +}); + +describe('chronik/actions', () => { + it('dismiss: PATCHes /api/notifications/{id}/read with the form id', async () => { + mockApi.PATCH.mockResolvedValue({ response: { ok: true } }); + const formData = new FormData(); + formData.set('id', 'n-42'); + + const result = await actions.dismiss({ + request: { formData: async () => formData }, + fetch + } as never); + + expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', { + params: { path: { id: 'n-42' } } + }); + expect(result).toEqual({ success: true }); + }); + + it('dismiss: fails with 400 when id is missing', async () => { + const formData = new FormData(); + const result = await actions.dismiss({ + request: { formData: async () => formData }, + fetch + } as never); + expect((result as { status: number }).status).toBe(400); + }); + + it('mark-all: POSTs /api/notifications/read-all', async () => { + mockApi.POST.mockResolvedValue({ response: { ok: true } }); + const result = await actions['mark-all']({ fetch } as never); + expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all'); + expect(result).toEqual({ success: true }); + }); +});