diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9424d65f..9b133bc7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -320,5 +320,19 @@ "dashboard_needs_metadata_show_all": "Alle anzeigen", "dashboard_recent_heading": "Zuletzt aktiv", "dashboard_resume_label": "Zuletzt geöffnet:", - "dashboard_resume_fallback": "Unbekanntes Dokument" + "dashboard_resume_fallback": "Unbekanntes Dokument", + "notification_view_all": "Alle anzeigen →", + "notification_history_heading": "Benachrichtigungen", + "notification_history_view_link": "Benachrichtigungsverlauf ansehen →", + "notification_filter_all": "Alle", + "notification_filter_unread": "Ungelesen", + "notification_filter_mention": "Erwähnung", + "notification_filter_reply": "Antwort", + "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", + "notification_load_more": "Ältere laden", + "notification_empty_history": "Keine Benachrichtigungen", + "notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.", + "notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}", + "notification_read_state_read": "gelesen", + "notification_read_state_unread": "ungelesen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4e1e071d..a452d12d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -320,5 +320,19 @@ "dashboard_needs_metadata_show_all": "Show all", "dashboard_recent_heading": "Recent Activity", "dashboard_resume_label": "Last opened:", - "dashboard_resume_fallback": "Unknown document" + "dashboard_resume_fallback": "Unknown document", + "notification_view_all": "View all →", + "notification_history_heading": "Notifications", + "notification_history_view_link": "View notification history →", + "notification_filter_all": "All", + "notification_filter_unread": "Unread", + "notification_filter_mention": "Mention", + "notification_filter_reply": "Reply", + "notification_mark_all_read_aria": "Mark all notifications as read", + "notification_load_more": "Load older", + "notification_empty_history": "No notifications", + "notification_empty_history_body": "Mentions and replies to your comments will appear here.", + "notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}", + "notification_read_state_read": "read", + "notification_read_state_unread": "unread" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 14c9f868..ab9c31e0 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -320,5 +320,19 @@ "dashboard_needs_metadata_show_all": "Ver todos", "dashboard_recent_heading": "Actividad reciente", "dashboard_resume_label": "Último abierto:", - "dashboard_resume_fallback": "Documento desconocido" + "dashboard_resume_fallback": "Documento desconocido", + "notification_view_all": "Ver todas →", + "notification_history_heading": "Notificaciones", + "notification_history_view_link": "Ver historial de notificaciones →", + "notification_filter_all": "Todas", + "notification_filter_unread": "No leídas", + "notification_filter_mention": "Mención", + "notification_filter_reply": "Respuesta", + "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", + "notification_load_more": "Cargar anteriores", + "notification_empty_history": "Sin notificaciones", + "notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.", + "notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}", + "notification_read_state_read": "leído", + "notification_read_state_unread": "no leído" } diff --git a/frontend/src/routes/notifications/+page.server.spec.ts b/frontend/src/routes/notifications/+page.server.spec.ts new file mode 100644 index 00000000..05d4fb2a --- /dev/null +++ b/frontend/src/routes/notifications/+page.server.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { load, actions } from './+page.server'; +import { createApiClient } from '$lib/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +function makeUrl(params: Record = {}) { + const url = new URL('http://localhost/notifications'); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return url; +} + +// ─── load ───────────────────────────────────────────────────────────────────── + +describe('notifications page load', () => { + it('returns notifications and unreadCount from API response', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { + content: [ + { id: 'n1', read: false }, + { id: 'n2', read: true }, + { id: 'n3', read: false } + ], + totalElements: 3, + totalPages: 1, + number: 0 + } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.notifications).toHaveLength(3); + expect(result.unreadCount).toBe(2); + }); + + it('passes type param to API when ?type=MENTION is in URL', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl({ type: 'MENTION' }), fetch: vi.fn() as unknown as typeof fetch }); + + const queryParams = mockGet.mock.calls[0][1].params.query; + expect(queryParams.type).toBe('MENTION'); + }); + + it('passes read=false to API when ?read=false is in URL', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl({ read: 'false' }), fetch: vi.fn() as unknown as typeof fetch }); + + const queryParams = mockGet.mock.calls[0][1].params.query; + expect(queryParams.read).toBe(false); + }); + + it('passes no filter params when no search params present', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + const queryParams = mockGet.mock.calls[0][1].params.query; + expect(queryParams.type).toBeUndefined(); + expect(queryParams.read).toBeUndefined(); + }); + + it('calls the API exactly once — no separate round-trip for unreadCount', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('throws 401 error when API returns 401', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: false, status: 401 }, + data: null + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await expect( + load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + ).rejects.toMatchObject({ status: 401 }); + }); +}); + +// ─── mark-all action ────────────────────────────────────────────────────────── + +describe('notifications mark-all action', () => { + it('calls POST /api/notifications/read-all and redirects', async () => { + const mockPost = vi.fn().mockResolvedValueOnce({ response: { ok: true } }); + vi.mocked(createApiClient).mockReturnValue({ POST: mockPost } as ReturnType< + typeof createApiClient + >); + + const markAll = actions['mark-all'] as (ctx: { fetch: typeof fetch }) => Promise; + await expect(markAll({ fetch: vi.fn() as unknown as typeof fetch })).rejects.toMatchObject({ + location: '/notifications' + }); + + expect(mockPost).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/routes/notifications/+page.server.ts b/frontend/src/routes/notifications/+page.server.ts new file mode 100644 index 00000000..42485660 --- /dev/null +++ b/frontend/src/routes/notifications/+page.server.ts @@ -0,0 +1,35 @@ +import { error, redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ fetch, url }) => { + const api = createApiClient(fetch); + + const type = url.searchParams.get('type') ?? undefined; + const readParam = url.searchParams.get('read'); + const read = readParam !== null ? readParam === 'true' : undefined; + + const result = await api.GET('/api/notifications', { + params: { query: { type: type as 'MENTION' | 'REPLY' | undefined, read, page: 0, size: 20 } } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); + } + + const page = result.data!; + const notifications = page.content ?? []; + const unreadCount = notifications.filter((n) => !n.read).length; + + return { notifications, unreadCount, totalPages: page.totalPages ?? 1 }; +}; + +export const actions: Actions = { + 'mark-all': async ({ fetch }) => { + const api = createApiClient(fetch); + await api.POST('/api/notifications/read-all'); + redirect(303, '/notifications'); + } +}; diff --git a/frontend/src/routes/notifications/+page.svelte b/frontend/src/routes/notifications/+page.svelte new file mode 100644 index 00000000..d073bc31 --- /dev/null +++ b/frontend/src/routes/notifications/+page.svelte @@ -0,0 +1,279 @@ + + + + {m.notification_history_heading()} + + +
+
+ + + + {m.btn_back_to_overview()} + + + +
+

+ {m.notification_history_heading()} +

+ {#if data.unreadCount > 0} +
+ +
+ {/if} +
+ + +
+ + + + + + + + + + + +
+ + + {#if allNotifications.length === 0} +
+ +

+ {m.notification_empty_history()} +

+

+ {m.notification_empty_history_body()} +

+
+ {:else} + + {/if} + + + {#if hasMore} + + {/if} +
+