feat: notification history page (/notifications) #153
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Users currently have a bell dropdown (latest 10) and a dashboard widget (latest 5). There is no way to browse older notifications. This issue tracks the implementation of a dedicated
/notificationsroute providing a full, paginated notification history.Design Decisions
Location:
/notifications— not the profile pageThe profile page serves identity management and notification preferences. Adding a history feed there would mix three distinct concerns. A dedicated route keeps the separation clean and matches the user's mental model: "I come here to catch up, not to change settings."
Entry points
Screen Design
Mobile layout (320px baseline)
Desktop (≥768px): same layout,
max-w-2xl mx-auto— no second column (linear reading content).Layout & Responsive Behaviour
Follows the project's standard shell pattern:
max-w-2xl— focused, linear reading; consistent with howmax-w-4xlis used on person detailpx-4 sm:px-6 lg:px-8— standard horizontal padding progression (16px → 24px → 32px)Component Spec
Notification row
px-4 py-4→px-6 py-5(md+)border-l-[3px] border-accent+ filled dot indicator (two cues, never color alone per WCAG 1.4.1)border-l-[3px] border-transparent+bg-canvasbackground<a>→/documents/{documentId}?commentId={referenceId}&annotationId={annotationId}Row content
font-serif font-semibold text-ink) + type badge ("Erwähnung" / "Antwort",font-sans text-xs uppercase tracking-wide bg-muted text-ink-2 px-2 py-0.5 rounded-sm)font-serif text-ink, hover underlinedecoration-accent)font-sans text-sm text-ink-3)aria-labelon each<a>:"{actor} hat {type} auf „{title}" — {time} — {read/unread}"Filter pills
role="radiogroup"+role="radio"+aria-checkedon each pillbg-muted text-ink font-sans text-sm font-medium px-3 py-2 rounded-fullbg-primary text-primary-fg font-sans text-sm font-medium px-3 py-2 rounded-fullfocus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"Alle als gelesen markieren"
unreadCount > 0(no orphaned controls)aria-label="Alle Benachrichtigungen als gelesen markieren"text-sm font-medium text-ink-2 hover:text-ink transition-colors underline decoration-accent/60 hover:decoration-accentEmpty state
aria-hidden)Pagination
Theming
The project uses semantic Tailwind utilities backed by CSS custom properties in
layout.css, which swap automatically between light and dark mode.bg-canvasbg-surfacebg-canvasbg-mutedborder-accent/bg-accenttext-inktext-ink-2text-ink-3text-ink-2border-linebg-primarytext-primary-fgFiles to create / modify
frontend/src/routes/notifications/+page.sveltefrontend/src/routes/notifications/+page.server.tsfrontend/src/lib/components/NotificationBell.sveltefrontend/src/routes/profile/+page.svelteAccessibility requirements
border-accent+ dot (not color alone)Out of scope
/profileArchitecture Review — @mkeller
Backend: already done (mostly)
GET /api/notificationsalready acceptspage,size,type, andreadparams.POST /api/notifications/read-allandPATCH /api/notifications/{id}/readboth exist. No new endpoints needed.The only gap:
NotificationDTOhas nodocumentTitle. The row design in the spec explicitly shows it — that field needs to be added to the record and populated inNotificationService(one join to documents). That's the only backend change.Filter pills → URL search params, not client state
The natural SvelteKit approach: filter changes call
goto('?type=MENTION')orgoto('?read=false'). The+page.server.tsload function readsurl.searchParamsand passes them straight to the API. Benefits: bookmarkable, back button works, SSR delivers the correct initial state. No$statevariable needed for the active filter — derive the active pill from$page.url.searchParams."Ältere laden" → hybrid: SSR first page, client-side append
Server renders page 0. The button does a client-side
fetchand appends to a local$statearray. SSR for the initial view (fast, no layout shift); append UX for subsequent loads. No full navigation on load-more.When a filter changes,
goto()triggers a full server re-render — page resets to 0 automatically. No stale pagination state to manage.Extract shared notification utilities before starting
relativeTime()and theNotificationItemtype currently live insideNotificationBell.svelte. The history page needs both. Extract to$lib/notifications.tsfirst — otherwise you end up with a second copy and a cleanup issue later.Unread count for the "Alle als gelesen markieren" button
The button should only render when
unreadCount > 0. That count should come from the server-loaded page data, not a separate API call. Either derive it from the loaded notifications or addunreadCountto the load return. Don't add a third round-trip just to conditionally render a button.Actual scope
NotificationDTO.javadocumentTitlefieldNotificationService.javadocumentTitlein mapping$lib/notifications.tsrelativeTime()+ typeNotificationBell.sveltenotifications/+page.server.tsnotifications/+page.svelteprofile/+page.svelteThe component spec and accessibility requirements are solid — no concerns there. The
role="radiogroup"/role="radio"/aria-checkedpattern for filter pills is correct; bindingaria-checkedto$page.url.searchParamsrather than a click handler means SSR and client navigation stay in sync automatically.Test Scope — @saraholt
What changed — and what needs tests
Two backend changes, one shared frontend utility extraction, three frontend files modified, two new frontend files. Here is the full coverage plan.
Layer 1 — Static Analysis (zero cost, already running)
No new configuration needed. TypeScript strict mode will catch the
documentTitlefield addition automatically oncenpm run generate:apiis re-run after the backend change. Verify:NotificationDTOtype in the generated client includesdocumentTitle: stringanycasts in the new$lib/notifications.tsLayer 2 — Unit Tests
Backend:
NotificationServiceTest.java(new test cases)NotificationServicegains a join to populatedocumentTitle. This is pure business logic and belongs in a unit test with Mockito.New test cases to add:
Use
@ExtendWith(MockitoExtension.class)— no Spring context. MockNotificationRepositoryandDocumentRepository. Assert on the returned DTO with AssertJ.Frontend:
$lib/notifications.ts(new test file:notifications.test.ts)relativeTime()is a pure function being extracted fromNotificationBell.svelte. Zero network dependencies — perfect unit test target.Test cases:
Layer 3 — Integration Tests
Backend:
NotificationControllerTest.java(add to existing@WebMvcTestslice)Use
@WebMvcTest(NotificationController.class)+ MockMvc. MockNotificationService— persistence is tested at the repository layer.Backend:
NotificationRepositoryTest.java(add only if JOIN lives in the repository)If
documentTitleis fetched via a JPQL/native query on the repository, add@DataJpaTestcases with Testcontainers (PostgreSQL 16, never H2):Frontend:
notifications/+page.server.tsload function (Vitest + MSW)That last case enforces Markus's architecture decision: no third API call just to conditionally render the mark-all button.
Frontend:
NotificationBell.svelteregression (Vitest + @testing-library/svelte)After the refactor to import from
$lib/notifications.ts:Layer 4 — E2E Tests (Playwright)
File:
frontend/e2e/notifications.spec.ts— critical journeys only.Journey 1: Navigate from bell dropdown
Journey 2: Filter by unread
Journey 3: Mark all as read
Journey 4: Load more
Journey 5: Row click → navigate and mark read
Journey 6: Empty state
Journey 7: Profile cross-link
Accessibility — runtime assertions (axe won't catch these)
role="radiogroup"; each pill hasrole="radio"andaria-checkedmatches the active URL param<a>hasaria-labelin the format:"{actor} hat {type} auf „{title}" — {time} — {read/unread}"aria-label="Alle Benachrichtigungen als gelesen markieren"aria-hidden="true"Visual regression
Capture at
320px,768px,1440pxin both light and dark mode:CI time impact
Within budget for all layers. No load test changes needed —
/notificationscalls the existingGET /api/notificationswhich is already covered by the nightly k6 suite.Security Review — @marcel
Nora "NullX" Steiner · App Sec · OSWE/BSCP
FINDING 1 — Medium | CWE-807 | Filter logic gap silently drops
readparamLocation:
NotificationService.java:96–107The "Ungelesen" filter pill calls
?read=falsewith no type. That request falls through to the last branch and returns every notification instead of only unread ones. This isn't a confidentiality breach — but it's a logic integrity failure: the UI presents a filtered view while delivering unfiltered data.Fix: Add the missing repository method and two missing service branches:
Detection:
NotificationServiceTest— addshould_return_only_unread_when_type_is_null_and_read_is_false. The test scope Sarah outlined already exercises?read=false— just make sure it asserts the result count, not only that the call succeeds.FINDING 2 — Low | CWE-770 | Unbounded
sizepagination parameterLocation:
NotificationController.java:47–48Without a cap, an authenticated user can request
?size=1000000, forcing a full table scan. Low blast radius for a family archive, but it costs nothing to add and closes the door before the history page makes this endpoint more attractive to hammer.Detection:
@WebMvcTest— addGET /api/notifications?size=200 → 400as a negative case.FINDING 3 — Low | CWE-20 | SSE payload parsed without runtime validation
Location:
NotificationBell.svelte:150If the backend emits a malformed event (serialization bug, schema evolution, future field rename), this silently inserts a broken object into
notifications. AnulldocumentIdfed intogoto()without a null check would produce an open redirect from backend-sourced data.Fix: Validate the shape at the boundary. Since
$lib/notifications.tsis being created as part of this issue anyway, put this guard in there:Apply the same pattern to the history page's client-side "load more" fetch response.
FINDING 4 — Informational | CWE-79 risk in implementation —
documentTitlefieldThe new
documentTitlefield is sourced from thedocumentstable — user-controlled input. CurrentactorNameis rendered safely via Svelte text interpolation ({...}auto-escapes). As long as the new history page never uses{@html documentTitle}, you're safe. The XSS surface opens the moment someone "optimises" the title rendering with{@html}.Make this an explicit PR review gate: no
{@html}on any field sourced from the notifications API.FINDING 5 — Informational | CSRF justification — verify client-side auth path
SecurityConfig.javadisables CSRF with the note that every request carries a Basic Auth header injected byhooks.server.ts. This holds for server-side load functions. ButNotificationBell.sveltemakes client-side fetches that don't go throughhooks.server.ts:The new history page adds another client-side fetch for "load more". Before writing it: confirm whether the Vite dev server (and production SvelteKit server) proxies
/api/*to the backend. If there's a same-origin proxy, all of this is fine and the CSRF comment holds. If client-side requests hit the backend cross-origin,EventSourceis unauthenticated and the other fetches rely on browser Basic Auth caching (fragile). Not a new bug from this issue — but worth settling before more client-side API calls are added.What's already correct — no action needed
markReadownership check (userIdmust matchnotification.recipient.id)typevaluesmarkAllReadscoped to authenticated user's ID only — no cross-user escalation/api/notifications/streamcovered byauth.anyRequest().authenticated()in SecurityConfigPriority order for implementation
read=falsewithout a type. Fix the backend before building the filter pill UI.@Max(100)tosize— 5-minute fix, do it while touching the controller fordocumentTitle.$lib/notifications.ts— the extraction is already planned, so the guard goes in at the same time.