refactor(notification-bell): use SvelteKit form actions instead of raw fetch #286
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?
Motivation
While discussing issue #285 (Chronik — unified activity + notifications page), we decided Chronik's Dismiss and "Alle gelesen" actions will use SvelteKit form actions (
<form method="POST" action="?/…" use:enhance>) — consistent with Nora's CSRF guidance and the existing admin patterns (admin/users,admin/tags, etc.).The existing
NotificationBell+NotificationDropdowncomponents call the same two endpoints (PATCH /api/notifications/{id}/read,POST /api/notifications/read-all) via rawfetch()from insideuseNotificationStream.svelte.ts. Shipping Chronik without touching the Bell leaves us with two code paths for the same operations:+page.server.tsaction →/api/notifications/{id}/readfetch()from the browser →/api/notifications/{id}/readThe inconsistency is cosmetic today (both work, both carry the auth cookie, both are
SameSite=Lax-protected) but will confuse the next developer who wonders "why does Chronik use a form action and the Bell doesn't?"Proposed change
Replace the raw
fetch()calls inNotificationDropdown.sveltewith SvelteKit form actions, matching the pattern Chronik introduces in #285.Affected files
frontend/src/lib/components/NotificationDropdown.svelte— per-notification Mark-read button wraps in a<form method="POST" action="/notifications/dismiss?id={n.id}" use:enhance>; Mark-all wraps similarly.frontend/src/lib/hooks/useNotificationStream.svelte.ts— removemarkReadandmarkAllReadfrom the exported API (or keep them as the mutation path called by the form action submit handlers). Update the optimistic-update logic to run inside theuse:enhancecallback instead of inline afterfetch().frontend/src/routes/notifications/dismiss/+page.server.ts(or a server action on an appropriate existing route) holding the form actions — alternatively, refactor to POST-only SvelteKit routes.Questions to resolve during implementation
/actions/notificationsroute group, a server endpoint under/api/wrapped in a SvelteKit proxy, or inline on the current page via+page.server.tson every route that renders the header. The third is ugly; one of the first two is right.useNotificationStreamshould expose a single optimistic-update helper (notifications.optimisticMarkRead(id)) that the form action's enhancement callback calls, decoupling "mutate the API" from "update the store."Acceptance
<form method="POST" … use:enhance>).PATCH /api/notifications/{id}/read,POST /api/notifications/read-all) are unchanged — this is a frontend refactor only.use:enhancecallback instead of immediately afterfetch().NotificationBellandNotificationDropdownspecs) updated and passing./api/notifications/*endpoints themselves.Blocked by
References
src/lib/components/NotificationBell.sveltesrc/lib/components/NotificationDropdown.sveltesrc/lib/hooks/useNotificationStream.svelte.ts👨💻 Felix Brandt — Senior Fullstack Developer
Observations
NotificationBell.svelteorNotificationDropdown.sveltetoday. The only adjacent test isfrontend/src/routes/notifications/page.server.spec.ts, which only covers the page route. The refactor is effectively writing these tests from scratch, not updating them.useNotificationStream.svelte.tscurrently does three things: SSE subscription, REST fetches, and mutation. The proposed split (mutations move out, stream stays) is the right SRP cleanup — the hook becomes "subscribe + read", and writes go through form actions.markReaddoesfetch→ mutate → return;NotificationBell.handleMarkReadthengoto(...). The user waits for the network round-trip before the navigation starts. Switching to a form action is the right time to fix that (optimistic mutate +gotoimmediately, fire-and-forget submission).notifications/+page.server.tsalready wires amark-allaction throughapi.POST('/api/notifications/read-all')and redirects — the new bell action should mirror that exactly so we have one pattern, not two.Recommendations
NotificationDropdown.spec.ts(vitest-browser-svelte) asserting that clicking a row submits a form to the dismiss action; writedismiss/+page.server.spec.tsmockingcreateApiClient, mirroring the existingmark-alltest. No production change before those are red.optimisticMarkRead(id)andoptimisticMarkAllRead()on the stream. The form action'suse:enhancecallback calls those synchronously before submit — that gives the existing instant UI without keepingfetch()in the hook. Removes thenotification.read = truemutation-via-prop pattern, which is fragile./notifications/dismiss/+page.server.ts. The/notificationsroute is being 301-redirected to/chronikby #285 (per its plan: "hooks.server.ts: 301 redirect /notifications → /chronik"). A POST to a 301-redirected path becomes a GET on the target — the form action breaks. Put the actions on/chronik/+page.server.ts(wheremark-allwill logically live anyway after #285) and submit absolute:<form action="/chronik?/dismiss-notification" method="POST" use:enhance>. One canonical home, lives next to its peer mutations.use:enhancewith explicit callback to keep navigation fast:?id={n.id}in the action attribute (as the issue body suggests) is brittle and inconsistent with the admin/tags pattern. Use<input type="hidden" name="notificationId" value={notification.id}>.Open Decisions
(none — sequencing under #285 resolves the route-home question)
🏛️ Markus Keller — Senior Application Architect
Observations
frontend/src/routes/notifications/dismiss/+page.server.tsroute conflicts with #285's plan to 301/notifications→/chronik. POST through a 301 becomes GET on the target — the action breaks. The route home isn't a stylistic choice; the dependency forces it onto/chronik.useNotificationStreamcurrently owns: (a) SSE subscription, (b) REST queries, (c) mutations. (a) and (b) are read concerns — the stream is the projection of unread state. (c) is a write concern. This refactor is a good moment to honor that boundary: the hook stops emitting writes, the form actions own them. The hook gains anoptimisticMarkRead(id)helper — that's a projection update, conceptually a read-side operation. Boundary intact.Recommendations
/chronikafter #285 ships. Names:dismiss-notification(single id via hidden input),mark-all-read(no body). They join the existingmark-allaction — net result: one route owns all notification write operations, and the SSE stream is read-only.useNotificationStreamas a read projection only. Public API after this refactor:markRead/markAllRead— those names imply a network call and would mislead future readers. Optimistic updates are a UI concern, not a transport concern./chronik. SSE stream is read-only. Optimistic UI lives in the stream hook as separately named helpers." This is the kind of structural decision that gets reverted by a future contributor "to simplify" if we don't write it down.Open Decisions
(none — the dependency on #285 sequences the route-home choice, and the hook API split is straightforward SRP)
🔐 Nora "NullX" Steiner — Application Security Engineer
Observations
fetch('/api/notifications/{id}/read', { method: 'PATCH' })with cookie auth is already protected —PATCHis not a form-submittable method, so a cross-origin attacker page cannot mint that request via a<form>. The benefit of moving to a form action is convention consistency, not a CSRF fix. Calling this a "security improvement" would overstate it.POSTto a SvelteKit route, then the SvelteKit Node server proxies thePATCHwith cookie forwarding. The cross-origin attack surface for the new path isPOST /chronik?/dismiss-notification— which is form-submittable. So the question becomes: what protects that path?SecurityConfig.java: CSRF is disabled at the Spring side because of header-based session auth on the API. The SvelteKit form-action endpoint isn't a Spring endpoint — it's a Node SSR handler. SvelteKit's default protection here is theOriginheader check on actions (built into the framework whencsrf.checkOriginis enabled, which it is by default). So we are not regressing — just shifting the protective layer.Recommendations
// CSRF: SvelteKit checks Origin header by default for form-action POSTs; backend session cookies are SameSite=Lax. Future-you in 6 months will thank present-you.dismiss/+page.server.spec.tsor wherever the action lands) — these are cheap and they document the boundary:notificationIdis missing or malformed.NotificationController.markReadpath: confirm it assertsnotification.recipient.id == currentUser.id. If not, that's a pre-existing IDOR — flag a separate issue, do not let this refactor mask it.log.debug("Dismissing notification {}", notificationId).Open Decisions
(none — the security posture is preserved as long as the recommendations above are followed)
🧪 Sara Holt — QA Engineer & Test Strategist
Observations
NotificationBellandNotificationDropdownspecs) updated and passing" is incorrect — those test files do not exist today. I checked:Glob frontend/**/Notification*.test.tsreturns nothing. The only related spec isfrontend/src/routes/notifications/page.server.spec.ts, covering the page route only. This refactor is adding test coverage, not updating it. Reword the acceptance line so the implementer doesn't waste 20 minutes hunting for files that aren't there.mark-allaction spec innotifications/page.server.spec.tsis a clean template: mockscreateApiClient, asserts the API was called once, asserts the redirect. Mirror that shape for the new actions and we get consistent coverage.Recommendations
vitest) —chronik/page.server.spec.ts(or wherever the actions land):dismiss-notificationcallsapi.PATCH('/api/notifications/{id}/read', { params: { path: { id }}})exactly once with the form-supplied id.dismiss-notificationreturns the SvelteKit form-action success shape (not a redirect — single dismiss should not navigate server-side; client navigates after).mark-all-readcallsapi.POST('/api/notifications/read-all')exactly once and returns success.fail(401)/fail(403)when the API does. Three failure paths, three tests.vitest-browser-svelte) —NotificationDropdown.spec.ts:stream.optimisticMarkRead(id)synchronously before the server response resolves (use a delayed mock, assert the unread dot disappears immediately)./chronik?/mark-all-read.Playwright) —bell.spec.ts:/documents/{id}?commentId=...AND that on return, the row appears as read in the bell list.javaScriptEnabled: false, verify the forms still submit and the page navigates correctly. This is the whole point of moving to form actions — if it doesn't work without JS, the refactor failed.npm run test(vitest covers the unit + component layers) AND the new E2E spec. Don't ship without the no-JS test — it's the test that proves the refactor delivered its claimed benefit.Open Decisions
(none — the test plan is straightforward; only requires the implementer to follow TDD)
🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
javaScriptEnabled: falsecontext support — already supported by Playwright out of the box, no infra change.Recommendations
/chronik, make sure the existing Caddy config doesn't have any path-specific rules on/notifications/*that would break the 301 from #285 — verify indocs/infrastructure/production-compose.md(or whichever Caddyfile is canonical) when reviewing the #285 PR, before this one starts.Open Decisions
(none — no infra impact)
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
<button onclick={() => onMarkRead(notification)}>— a single accessible focusable element with a clean accessible name (the notification text). Wrapping it in<form><button type="submit">…</button></form>adds a<form>ancestor inside<li>inside[role="dialog"]. This is structurally fine for screen readers but worth verifying — axe and a manual VoiceOver/NVDA pass should run on the open dropdown.flex w-full cursor-pointer). The form wrapping must not break that — the<button>must remainw-full, must keep the existingmin-h-[44px]-ish padding (it currently usespx-4 py-3which yields ~50px — borderline acceptable for the senior audience), and the form itself must havedisplay: contentsor no padding/margin so it doesn't introduce a visible gap.display: contents, but it changes the focus order if the form introduces an extra tab stop. Verify keyboard tab order is unchanged.aria-live="polite". The optimistic decrement ofunreadCountwill still announce. Verify the form-action path doesn't cause a double-announce (one optimistic, one on response).Recommendations
display: contentson the form so it has no layout impact:py-3topy-3.5(14px) while we're here — gets us to 52px row height, comfortably above the 44px WCAG 2.2 minimum and friendlier for the 60+ audience. This is a one-line change; do it as part of the refactor or skip it, but don't half-do it.role="button"from Tailwind reset — verify, don't assume.closeDropdown()→bellButtonEl?.focus()path inNotificationBell.sveltemust run reliably from theuse:enhancecallback.aria-labelon the bell button — the unread-count branch (stream.unreadCount > 0 ? unread label : default) must still flip correctly when the optimistic update runs. Add a test that asserts the aria-label changes after the optimistic mutation, before the server response.Open Decisions
(none — all changes are concrete a11y/UX preservation work)