From 9d283c4500a463a9f009ed09114e13285c2eed3a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 22:51:08 +0200 Subject: [PATCH 01/11] feat(notification): add dismiss-notification and mark-all-read form actions to aktivitaeten Adds two SvelteKit form actions to /aktivitaeten/+page.server.ts so the notification bell can POST there instead of calling the backend directly from the browser. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/aktivitaeten/+page.server.ts | 28 +++++++ .../routes/aktivitaeten/page.server.spec.ts | 80 ++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/aktivitaeten/+page.server.ts b/frontend/src/routes/aktivitaeten/+page.server.ts index 21df3e85..940cedb6 100644 --- a/frontend/src/routes/aktivitaeten/+page.server.ts +++ b/frontend/src/routes/aktivitaeten/+page.server.ts @@ -1,4 +1,6 @@ +import { fail } from '@sveltejs/kit'; import { createApiClient } from '$lib/shared/api.server'; +import { getErrorMessage } from '$lib/shared/errors'; import type { components, operations } from '$lib/generated/api'; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; @@ -65,3 +67,29 @@ export async function load({ fetch, url }) { loadError }; } + +export const actions = { + 'dismiss-notification': async ({ request, fetch }) => { + const data = await request.formData(); + const notificationId = data.get('notificationId') as string; + const api = createApiClient(fetch); + const result = await api.PATCH('/api/notifications/{id}/read', { + params: { path: { id: notificationId } } + }); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + return { success: true }; + }, + + 'mark-all-read': async ({ fetch }) => { + const api = createApiClient(fetch); + const result = await api.POST('/api/notifications/read-all'); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + return { success: true }; + } +}; diff --git a/frontend/src/routes/aktivitaeten/page.server.spec.ts b/frontend/src/routes/aktivitaeten/page.server.spec.ts index 31a1619f..fbff2670 100644 --- a/frontend/src/routes/aktivitaeten/page.server.spec.ts +++ b/frontend/src/routes/aktivitaeten/page.server.spec.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { load } from './+page.server'; +import { load, actions } from './+page.server'; const mockApi = { - GET: vi.fn() + GET: vi.fn(), + PATCH: vi.fn(), + POST: vi.fn() }; vi.mock('$lib/shared/api.server', () => ({ @@ -173,3 +175,77 @@ describe('aktivitaeten/load — kinds param per filter', () => { expect(call[1].params.query.kinds).toHaveLength(2); }); }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeActionEvent(formData: FormData): any { + return { + request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }), + fetch + }; +} + +describe('aktivitaeten/actions — dismiss-notification', () => { + it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => { + mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', { + params: { path: { id: 'n-abc' } } + }); + }); + + it('returns { success: true } when the API responds ok', async () => { + mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + const result = await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(result).toEqual({ success: true }); + }); + + it('returns fail(status, { error }) when the API responds non-ok', async () => { + mockApi.PATCH.mockResolvedValue({ + response: { ok: false, status: 403 }, + error: { code: 'NOTIFICATION_NOT_FOUND' } + }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + const result = await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(result).toMatchObject({ status: 403 }); + }); +}); + +describe('aktivitaeten/actions — mark-all-read', () => { + it('calls POST /api/notifications/read-all', async () => { + mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null }); + + await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all'); + }); + + it('returns { success: true } when the API responds ok', async () => { + mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null }); + + const result = await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(result).toEqual({ success: true }); + }); + + it('returns fail(status, { error }) when the API responds non-ok', async () => { + mockApi.POST.mockResolvedValue({ + response: { ok: false, status: 500 }, + error: { code: 'INTERNAL_ERROR' } + }); + + const result = await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(result).toMatchObject({ status: 500 }); + }); +}); -- 2.49.1 From c0a7408ef4db0738703706520f39b0c72ba33700 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 22:59:01 +0200 Subject: [PATCH 02/11] refactor(notification): rename markRead/markAllRead to optimistic helpers without fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes raw fetch() calls from the store. optimisticMarkRead(id) and optimisticMarkAllRead() now only mutate local $state — the actual API calls move to SvelteKit form actions on /aktivitaeten. Co-Authored-By: Claude Sonnet 4.6 --- .../notification/notifications.svelte.spec.ts | 42 +++++++++++++++++-- .../lib/notification/notifications.svelte.ts | 31 +++++--------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/frontend/src/lib/notification/notifications.svelte.spec.ts b/frontend/src/lib/notification/notifications.svelte.spec.ts index f4bb1ca0..15345286 100644 --- a/frontend/src/lib/notification/notifications.svelte.spec.ts +++ b/frontend/src/lib/notification/notifications.svelte.spec.ts @@ -108,12 +108,46 @@ describe('notificationStore (singleton)', () => { expect(notificationStore.unreadCount).toBe(1); }); - it('markAllRead resets unreadCount', async () => { - mockFetch.mockResolvedValue(new Response(null, { status: 200 })); - await notificationStore.markAllRead(); + it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { + notificationStore.init(); + const notification = makeNotification({ id: 'sse-1', read: false }); + lastEventSource!.simulate('notification', JSON.stringify(notification)); + mockFetch.mockReset(); // clear the fetchUnreadCount call from init - expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' }); + notificationStore.optimisticMarkRead('sse-1'); + + expect(notificationStore.notifications[0].read).toBe(true); expect(notificationStore.unreadCount).toBe(0); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => { + notificationStore.init(); + const notification = makeNotification({ id: 'sse-1', read: true }); + lastEventSource!.simulate('notification', JSON.stringify(notification)); + + notificationStore.optimisticMarkRead('sse-1'); + + expect(notificationStore.unreadCount).toBe(0); + }); + + it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => { + notificationStore.init(); + lastEventSource!.simulate( + 'notification', + JSON.stringify(makeNotification({ id: 'n1', read: false })) + ); + lastEventSource!.simulate( + 'notification', + JSON.stringify(makeNotification({ id: 'n2', read: false })) + ); + mockFetch.mockReset(); + + notificationStore.optimisticMarkAllRead(); + + expect(notificationStore.unreadCount).toBe(0); + expect(notificationStore.notifications.every((n) => n.read)).toBe(true); + expect(mockFetch).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/lib/notification/notifications.svelte.ts b/frontend/src/lib/notification/notifications.svelte.ts index 2ac49e4e..1e52cfe3 100644 --- a/frontend/src/lib/notification/notifications.svelte.ts +++ b/frontend/src/lib/notification/notifications.svelte.ts @@ -35,28 +35,19 @@ async function fetchUnreadCount(): Promise { } } -async function markRead(notification: NotificationItem): Promise { - if (!notification.read) { - try { - await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' }); - notification.read = true; - unreadCount = Math.max(0, unreadCount - 1); - } catch (e) { - console.error('Failed to mark notification as read', e); - } +function optimisticMarkRead(id: string): void { + const notification = notifications.find((n) => n.id === id); + if (notification && !notification.read) { + notification.read = true; + unreadCount = Math.max(0, unreadCount - 1); } } -async function markAllRead(): Promise { - try { - await fetch('/api/notifications/read-all', { method: 'POST' }); - for (const n of notifications) { - n.read = true; - } - unreadCount = 0; - } catch (e) { - console.error('Failed to mark all notifications as read', e); +function optimisticMarkAllRead(): void { + for (const n of notifications) { + n.read = true; } + unreadCount = 0; } function init(): void { @@ -123,8 +114,8 @@ export const notificationStore = { }, fetchNotifications, fetchUnreadCount, - markRead, - markAllRead, + optimisticMarkRead, + optimisticMarkAllRead, init, destroy }; -- 2.49.1 From 2c6b59d0c7877a249e97d668e86f9bf25fbf7ad7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 23:15:56 +0200 Subject: [PATCH 03/11] refactor(notification): replace callback props with form actions in Dropdown and Bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationDropdown now wraps each row in a
and the mark-all control in , wired via use:enhance for optimistic UI. Props renamed onMarkRead/onMarkAllRead → optimisticMarkRead/optimisticMarkAllRead to match the simplified store API. NotificationBell passes the store helpers directly; handleMarkRead is removed. Test mocks updated: $app/forms enhance mock fires SubmitFunction synchronously on form submit so callback assertions work without a real HTTP round-trip. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/notification/NotificationBell.svelte | 17 +- .../NotificationBell.svelte.spec.ts | 59 ++---- .../notification/NotificationDropdown.svelte | 168 +++++++++++------- .../NotificationDropdown.svelte.test.ts | 141 ++++++++++----- 4 files changed, 218 insertions(+), 167 deletions(-) diff --git a/frontend/src/lib/notification/NotificationBell.svelte b/frontend/src/lib/notification/NotificationBell.svelte index 3648f95f..3a04bb35 100644 --- a/frontend/src/lib/notification/NotificationBell.svelte +++ b/frontend/src/lib/notification/NotificationBell.svelte @@ -1,10 +1,8 @@
+ {#if errorMessage} + + {/if} {#if unread.length === 0}
{ + errorMessage = null; optimisticMarkAllRead(); - return async ({ update }) => { + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + } await update({ reset: false, invalidateAll: false }); }; }} @@ -115,8 +124,12 @@ function href(n: NotificationItem): string { action="/aktivitaeten?/dismiss-notification" method="POST" use:enhance={() => { + errorMessage = null; optimisticMarkRead(n.id); - return async ({ update }) => { + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + } await update({ reset: false, invalidateAll: false }); }; }} diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts index 44099b40..8ffd6713 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts @@ -5,18 +5,36 @@ import { page, userEvent } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte'; +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + vi.mock('$app/forms', () => ({ - enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) { - const handler = (e: Event) => { + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { e.preventDefault(); - submit?.({ formData: new FormData(node) } as never); + const cb = submit?.({ formData: new FormData(node) } as never); + if (typeof cb === 'function') { + await ( + cb as (o: { result: typeof mockFormResult; update: () => Promise }) => Promise + )({ result: mockFormResult, update: async () => {} }); + } }; node.addEventListener('submit', handler); return { destroy: () => node.removeEventListener('submit', handler) }; } })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + mockFormResult.type = 'success'; +}); function notif(partial: Partial): NotificationItem { return { @@ -156,4 +174,22 @@ describe('ChronikFuerDichBox', () => { // Prevents the senior-audience tap-drag bug flagged by Leonie. expect(dismiss?.closest('a')).toBeNull(); }); + + it('shows an accessible error banner when the dismiss action returns a failure', async () => { + mockFormResult.type = 'failure'; + render(ChronikFuerDichBox, { + unread: [notif({ id: 'err-1' })], + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() + }); + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLButtonElement | null; + expect(dismiss).not.toBeNull(); + dismiss?.click(); + // Allow microtask queue to flush + await new Promise((r) => setTimeout(r, 0)); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); }); diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts index dfc271f6..2ee844e3 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts @@ -4,18 +4,36 @@ import { page } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications'; +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + vi.mock('$app/forms', () => ({ - enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) { - const handler = (e: Event) => { + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { e.preventDefault(); - submit?.({ formData: new FormData(node) } as never); + const cb = submit?.({ formData: new FormData(node) } as never); + if (typeof cb === 'function') { + await ( + cb as (o: { result: typeof mockFormResult; update: () => Promise }) => Promise + )({ result: mockFormResult, update: async () => {} }); + } }; node.addEventListener('submit', handler); return { destroy: () => node.removeEventListener('submit', handler) }; } })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + mockFormResult.type = 'success'; +}); const mention = (overrides: Partial = {}): NotificationItem => ({ id: 'n-1', @@ -140,4 +158,24 @@ describe('ChronikFuerDichBox', () => { const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; expect(link.getAttribute('href')).toContain('doc-x'); }); + + it('shows an accessible error banner when the dismiss action returns a failure', async () => { + mockFormResult.type = 'failure'; + render(ChronikFuerDichBox, { + props: { + unread: [mention({ id: 'err-1' })], + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} + } + }); + + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLElement; + dismiss.click(); + // Allow microtask queue to flush + await new Promise((r) => setTimeout(r, 0)); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); }); -- 2.49.1 From b5239f515f081508d4e01fca24e3a60afe40a827 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 07:31:26 +0200 Subject: [PATCH 11/11] fix(notification): address review suggestions - ChronikFuerDichBox: move update() inside the failure branch so success path skips it, matching NotificationDropdown's pattern - NotificationDropdown test: add role=alert assertion for mark-all-read failure to match existing dismiss-failure coverage in ChronikFuerDichBox - +page.server.ts: use getErrorMessage(undefined) instead of null so the missing-notificationId 400 goes through the same i18n pipeline as other errors Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/activity/ChronikFuerDichBox.svelte | 4 ++-- .../NotificationDropdown.svelte.test.ts | 17 +++++++++++++++++ .../src/routes/aktivitaeten/+page.server.ts | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte b/frontend/src/lib/activity/ChronikFuerDichBox.svelte index 18c2edff..ac893171 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte @@ -81,8 +81,8 @@ function href(n: NotificationItem): string { return async ({ result, update }) => { if (result.type === 'failure' || result.type === 'error') { errorMessage = m.notification_error_generic(); + await update({ reset: false, invalidateAll: false }); } - await update({ reset: false, invalidateAll: false }); }; }} > @@ -129,8 +129,8 @@ function href(n: NotificationItem): string { return async ({ result, update }) => { if (result.type === 'failure' || result.type === 'error') { errorMessage = m.notification_error_generic(); + await update({ reset: false, invalidateAll: false }); } - await update({ reset: false, invalidateAll: false }); }; }} > diff --git a/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts b/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts index 459bd72b..bdbda7eb 100644 --- a/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts +++ b/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts @@ -234,6 +234,23 @@ describe('NotificationDropdown', () => { expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); }); + it('shows a role=alert error banner when mark-all-read returns a failure', async () => { + mockFormResult.type = 'failure'; + render(NotificationDropdown, { + props: { + notifications: [makeNotification()], + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {}, + onClose: () => {} + } + }); + + await page.getByRole('button', { name: /alle gelesen/i }).click(); + + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); + it('calls onClose when the view-all button is clicked', async () => { const onClose = vi.fn(); render(NotificationDropdown, { diff --git a/frontend/src/routes/aktivitaeten/+page.server.ts b/frontend/src/routes/aktivitaeten/+page.server.ts index 8d5e3fb1..368b7bc3 100644 --- a/frontend/src/routes/aktivitaeten/+page.server.ts +++ b/frontend/src/routes/aktivitaeten/+page.server.ts @@ -73,7 +73,7 @@ export const actions = { const data = await request.formData(); const raw = data.get('notificationId'); const notificationId = typeof raw === 'string' ? raw : null; - if (!notificationId) return fail(400, { error: null }); + if (!notificationId) return fail(400, { error: getErrorMessage(undefined) }); const api = createApiClient(fetch); const result = await api.PATCH('/api/notifications/{id}/read', { params: { path: { id: notificationId } } -- 2.49.1