refactor(notification): use SvelteKit form actions instead of raw fetch #630
Closed
marcel
wants to merge 11 commits from
feat/issue-286-notification-bell-form-actions into main
pull from: feat/issue-286-notification-bell-form-actions
merge into: marcel:main
marcel:main
marcel:feat/issue-753-journey-editor
marcel:feat/issue-750-lesereisen-data-model
marcel:worktree-feat+issue-738-nl-search-backend
marcel:feat/issue-580-sentry-backend
marcel:fix/issue-593-management-port-zero
marcel:worktree-feat+issue-557-upload-artifact-v3-pin
marcel:worktree-chore+issue-556-drop-client-branches-coverage-gate
marcel:fix/issue-514-prerender-crawl-bakes-redirects
marcel:fix/issue-472-prerender-entries
marcel:feat/issue-395-readme
marcel:feat/issue-345-bulk-mark-reviewed
marcel:feat/issue-344-bell-tooltip
marcel:feat/issue-341-annotieren-contrast
marcel:feat/issue-225-bulk-metadata-edit
marcel:feat/issue-317-bulk-upload
marcel:feat/issue-271-dashboard-redesign
marcel:docs/issue-240-mission-control-spec
marcel:refactor/issues-193-200
marcel:feat/issue-162-korrespondenz-redesign
marcel:feature/68-new-document-file-first
marcel:feat/81-discussion-discoverability
marcel:feat/62-document-bottom-panel
marcel:feature/56-backfill-file-hashes
marcel:feat/38-document-edit-history
marcel:fix/svelte5-test-delegation-and-login-auth
No Reviewers
Labels
Clear labels
P0-critical
P1-high
P2-medium
P3-later
audit
bug
cleanup
collaboration
conversation
descoped
devops
documentation
epic
feature
file-upload
legibility
needs-discussion
notification
person
phase-1: security
phase-2: container-images
phase-3: prod-compose
phase-4: spring-prod-profile
phase-5: backups
phase-6: deployment-docs
phase-7: monitoring
refactor
security
test
ui
user
Blocks a core user journey, causes data loss, or is a security/accessibility barrier. Work on this before P1+.
Significant friction on a main user journey; workaround exists but is non-obvious. Next up after P0.
Noticeable improvement; doesn't block anything; schedule opportunistically.
Cosmetic or parking-lot work; fine to stay open indefinitely.
Read-only audit / assessment work; produces a report rather than changing code
Something isn't working
Removal of dead code, vague comments, naming violations, and scratch artifacts
We want to extend the family archive to have more collaboration tools
We will do that later
README, ARCHITECTURE, GLOSSARY, CONTRIBUTING, per-domain docs
Marker for epic-level issues that group multiple child issues
Codebase Legibility Refactor — preparing the codebase for human evaluation and bus-factor reduction
Has an open decision or design question that must be resolved before implementation can start.
Security hygiene — must be done first
Production-ready multi-stage Docker images
Production compose overlay + Caddy reverse proxy
Spring Boot production configuration profile
Database and object storage backup strategy
.env.example, DEPLOYMENT.md, runbook
Prometheus, Loki, Grafana, Alertmanager
Code restructuring without behaviour change
UI/UX and accessibility issues
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: marcel/familienarchiv#630
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/issue-286-notification-bell-form-actions"
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?
$(cat <<'EOF'
Closes #286
Summary
dismiss-notificationandmark-all-readform actions toaktivitaeten/+page.server.tsso notification writes go through SvelteKit instead of rawfetch()markRead/markAllRead→optimisticMarkRead(id)/optimisticMarkAllRead()— pure local state mutations, no network callsNotificationDropdown.svelteandChronikFuerDichBox.svelteto use<form method="POST" use:enhance>for dismiss and mark-all-read; JS-off degradation now works via native form POSThandleMarkReadwrapper fromNotificationBell.svelte;aktivitaeten/+page.sveltepasses store helpers directlyTest plan
npx vitest run src/routes/aktivitaeten/page.server.spec.ts— 6 new tests (dismiss + mark-all, happy path + API error + missing field)notifications.svelte.spec.ts— renamed helper tests confirm no fetch calls fireNotificationDropdown.svelte.test.ts,NotificationBell.svelte.spec.ts,ChronikFuerDichBox.svelte.spec.ts,ChronikFuerDichBox.svelte.test.ts— all updated for form-based API🤖 Generated with Claude Code
EOF
)
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Good refactor. The split between server-side HTTP calls and client-side optimistic state is clean, the naming is mostly improved, and the
use:enhancepattern matches the project standard. A few things need attention.Blockers
1.
SubmitFunctionfires before the POST — optimistic update runs on network failure tooIn
NotificationDropdown.svelte(lines 85–97) andChronikFuerDichBox.svelte, theuse:enhancecallback runs synchronously on submit, unconditionally callingoptimisticMarkRead(notification.id),onClose(), andgoto(...)before the server responds. If the action returnsfail(), the optimistic mark-read has already fired, the dropdown is already closed, and the user has already been navigated away. The server-side state is out of sync with what the UI shows, with no recovery path and no error surfaced to the user.The correct sequence for a dismiss action that navigates is:
Right now
onClose()andgoto()are inside the SubmitFunction body, not the callback — they execute before the server responds, making it impossible to handle failures. This is the key correctness issue in the whole PR.2. Missing
notificationIdvalidation in thedismiss-notificationaction+page.server.tsline 74:const notificationId = data.get('notificationId') as string;formData.get()returnsFormDataEntryValue | null. Theas stringcast bypasses TypeScript's null check. If the field is missing or empty,notificationIdwill benullcast tostring, andcreateApiClient(fetch).PATCH('/api/notifications/{id}/read', { params: { path: { id: null } } })will make a request to/api/notifications/null/read. The backend will return 404 or 400, which is handled — but the frontend should reject missing IDs before making the network call:Suggestions
3.
use:enhancemock doesn't simulate the async callback —update()never runsThe mock in all five test files (
ChronikFuerDichBox.svelte.spec.ts,NotificationDropdown.svelte.test.ts, etc.):The real
enhancepasses aSubmitFunctionthat returns a callback({ result, update }) => Promise<void>. The mock calls the outer function synchronously but never calls the returned callback, soupdate({ reset: false })never executes in tests. This means the mock doesn't verify the async path. It's acceptable for testing the optimistic call, but it means there are no tests proving theupdate()branch works correctly — particularly relevant given blocker #1.4.
fail()response is never displayed to the userBoth actions can return
fail(status, { error: string }), but no component reads$page.formorform.errorto display the message. The user gets silent failure — the optimistic update fires, the dropdown closes, and if the server returned an error, nothing tells the user. Even a simple error toast or reverting the optimistic state would be sufficient.5. Prop naming —
optimisticMarkRead/optimisticMarkAllReadare accurate but verboseThe names clearly signal client-only intent, which is good. A minor concern: they're now passed as props through two component layers (Bell → Dropdown, Chronik Page → ChronikFuerDichBox). Consider whether a callback with a clearer action-oriented name (
onDismiss,onDismissAll) would read more naturally at the call site, with the "optimistic" implementation detail staying internal to the store. That said, the current naming is not wrong — it's a readability trade-off.6. Dead code: deleted
handleMarkReadtests inNotificationBell.svelte.spec.tsThe two tests that verified navigation with/without
annotationIdwere deleted rather than migrated. The navigation logic now lives insideNotificationDropdown.svelte'suse:enhancecallback (line 88–94). There is no test asserting that clicking a notification row navigates tobuildCommentHref(documentId, referenceId, annotationId). That's a coverage gap on a user-visible behavior.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
This PR is primarily a frontend refactor, so the attack surface is small. The backend IDOR protection is unchanged and the session-based auth cookie forwarding through SvelteKit's
fetchis correct. A few things to flag.Blockers
1. Unvalidated form input reaches the API path parameter
+page.server.tsline 74:formData.get()returnsFormDataEntryValue | null. Theas stringcast removes the null guard. An attacker with JS disabled who manually crafts a form POST to/aktivitaeten?/dismiss-notificationwithout anotificationIdfield will cause this to benullcast tostring, which then gets interpolated into the API path:/api/notifications/null/read. The backend will 404 or 400 (so no exploitation here), but the missing guard is a code smell that can mask worse bugs in similar actions added later.Fix:
Security note on IDOR: The backend
PATCH /api/notifications/{id}/readenforces ownership server-side (authenticated session + notification belongs to calling user), so there is no IDOR vulnerability introduced here. ThenotificationIdcomes from the store, not from user input, under JS-on flow. Under JS-off flow it comes from the hidden input, but the backend validates ownership either way. This is correctly handled.Suggestions
2. The
notificationIdin the hidden<input>is visible in the DOMtype="hidden"does not prevent a user from viewing or modifying the value via browser DevTools. Under JS-on flow withuse:enhance, this is harmless because the backend validates ownership. Under JS-off flow (native form POST), the same backend ownership check applies. This is not a vulnerability, but it's worth noting for completeness: thenotification.id(a UUID) is exposed in the HTML source. That's acceptable given UUIDs are not a secret, but teams sometimes have concerns about ID enumeration.3.
fail()response includes a localized error stringgetErrorMessage()maps error codes to i18n strings. This is correct — no raw backend error messages, no stack traces, no implementation details. This is the right pattern and matches the project's error handling standard. Good.4. No CSRF exposure from the
use:enhanceapproachSvelteKit form actions use the session cookie (HttpOnly, SameSite) for auth. The
use:enhanceintercept does not change the CSRF properties — form POSTs to same-origin actions are SameSite-protected. No concern here.Nit
5.
makeActionEventinpage.server.spec.tsuses the globalfetchThe
fetchhere is the real globalfetch, but the test mockscreateApiClientso it doesn't actually use thisfetch. Functionally fine in tests, but the// eslint-disable-next-line @typescript-eslint/no-explicit-anyabove the function and the barefetchreference look like implementation shortcuts that could cause confusion when the test infra changes. Worth documenting with a comment.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
This refactor brings notification writes into the SvelteKit form action layer — the right place for server-side writes in this stack. The boundary between server action (HTTP) and client store (optimistic UI) is now explicit and correct. No doc updates are required: no new routes, no new backend packages, no new DB migrations, no new Docker services. The existing
aktivitaetenroute just gains actions.Observations
1. The
use:enhanceSubmitFunction ordering is architecturally concerningAs Felix correctly notes,
onClose()andgoto()currently execute in the SubmitFunction body before the server responds. From an architecture perspective, this is a layering issue: the UI state transition (close + navigate) is being triggered optimistically without a rollback path. This is fine if the product decision is "we accept that failed server calls don't undo the navigation", but that decision should be explicit. The current implementation makes it accidental rather than intentional.2. Hardcoded action path
/aktivitaeten?/mark-all-readin components outside the routeNotificationDropdown.svelteandChronikFuerDichBox.svelteare in$lib/notification/and$lib/activity/respectively — library components, not route-specific components. They now contain a hardcoded absolute path/aktivitaeten?/mark-all-read. This creates a coupling between a library component and a specific route.If the route is ever renamed or if these components are ever reused in a different context, the hardcoded paths will silently break (the native form POST goes to the wrong place;
use:enhanceintercepts it, so JS-on users won't notice). The correct approach is to pass the action URL as a prop, or accept that these components are route-bound and move them into the route folder:This is a design smell, not a blocker, but it should be addressed before the components are ever reused.
3. Module structure is clean
The separation of
optimisticMarkRead/optimisticMarkAllRead(pure state mutation, no network) from the server actions (network, session auth) is the correct split. The store is now a pure UI state manager; the SvelteKit action is the API gateway. This is a better architecture than the originalfetch()calls inside the store.4. No doc updates triggered by this PR
CLAUDE.mdroute table update needed✅ This PR correctly requires no documentation updates.
🧪 Sara Holt — QA Engineer & Test Automation Specialist
Verdict: ⚠️ Approved with concerns
The new server action tests (
page.server.spec.ts) are solid — three cases per action (happy path, API error, missing field implied by error test). The store tests foroptimisticMarkRead/optimisticMarkAllReadcorrectly verify no fetch is called. The component test updates are mechanical and correct. Two gaps worth addressing.Blockers
1. Navigation behavior after dismiss is untested
The previous
NotificationBell.svelte.spec.tshad two tests verifying thatgoto()was called with the correct URL (with and withoutannotationId). These tests were deleted. The navigation now happens insideNotificationDropdown.svelte'suse:enhanceSubmitFunction — but the$app/formsmock doesn't invoke the returned callback, sogoto()is never called in tests.Concretely: there is no test asserting that clicking a notification row in the dropdown navigates to
/documents/{documentId}?commentId={referenceId}or/documents/{documentId}?commentId={referenceId}&annotationId={annotationId}. This was a user-visible behavior that was tested and is now untested. It should be restored inNotificationDropdown.svelte.test.ts.2.
fail()response path is never asserted in component testsThe server action tests verify that
fail(status, { error })is returned. But no component test verifies that when the enhance callback receivesresult.type === 'failure', the error is surfaced to the user (e.g., a toast, an inline message, or at least thatupdate()is called). Since the current code silently swallows failures (theupdate()call withinvalidateAll: falseeffectively discards thefail()payload), this is a behavior gap — but it's also untested, so no test currently documents that the failure is intentionally silent.Suggestions
3.
use:enhancemock is duplicated in 5 filesThe identical mock block appears verbatim in:
ChronikFuerDichBox.svelte.spec.tsChronikFuerDichBox.svelte.test.tsNotificationBell.svelte.spec.tsNotificationDropdown.svelte.test.tsExtract to a shared test helper:
DRY violation in test code is as costly as in production code — when the mock needs to change (e.g., to test the
resultcallback), you'll have to update five files.4.
page.server.spec.tsmissing test:dismiss-notificationwith missingnotificationIdThe PR description says there are tests for "missing field", but looking at the actual tests:
calls PATCH ... with the form-supplied notificationId✅returns { success: true } when the API responds ok✅returns fail(status, { error }) when the API responds non-ok✅There is no test for when
notificationIdis absent from the FormData (nofd.set('notificationId', ...)call). Given theas stringnull cast issue, this is a gap. Add:Nit
5. Test name inconsistency between the two spec files for
ChronikFuerDichBoxThere are two test files for the same component:
ChronikFuerDichBox.svelte.spec.tsandChronikFuerDichBox.svelte.test.ts. Both cover overlapping behaviors (dismiss button, mark-all-read). This is a pre-existing issue, not introduced by this PR — but worth noting that duplicate coverage of the same component across two files makes it harder to know where to add new tests.🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
The structural changes from
<button onclick>to<form use:enhance>are sound and improve JS-off degradation. Most of the visual output is unchanged. A few accessibility regressions and concerns to flag.Blockers
1.
<form class="contents">breaks focus ring and hover state on the notification rowNotificationDropdown.svelteline 84:class="contents"on the<form>removes it from layout. The<button type="submit">inside then carries all the visual classes (flex, border, hover, etc.) — that part is fine. However,class="contents"means the<form>itself has no visual presence, but assistive technologies (particularly older screen readers) may still announce the form as a group. More importantly, the<form>doesn't receive focus, so there's no accessible group role communicating that this is a "dismiss notification" unit.This is not catastrophically broken — the button inside still has its text content read aloud — but it's an unusual pattern. I'd check with axe-core whether
<form class="contents">withuse:enhanceinside a<ul role="list">produces any ARIA tree issues. The alternative is to not useclass="contents"and instead apply the layout classes to the<form>itself (making itdisplay: flex).2. Dismiss action errors are never communicated to the user
When
dismiss-notificationreturnsfail(), there is no visible error state. The user clicks dismiss, the unread dot disappears (optimistic update), and if the server fails, there is no recovery, no toast, no inline error. For senior users (60+) who rely on visual confirmation that their action succeeded, silent failure violates Nielsen heuristic #1 (visibility of system status). This is a design problem, not just an engineering one — the UX requirement for failure feedback was not specified in the original issue.Minimum viable: a toast notification using the existing notification infrastructure or a simple
aria-liveregion on the page.Suggestions
3.
mark-all-readform in the dropdown header has no label or accessible nameNotificationDropdown.sveltelines 36–52: the<form>wrapping "Alle gelesen" contains only a<button>. The form itself has noaria-label. While the button text provides its own label, some screen readers announce the form as a container before the button. Addingaria-labelto the form (oraria-hidden="true"if the form element itself is noise) would clarify intent for AT users.4. The
ChronikFuerDichBoxdismiss form is also missing an accessible name for the form groupingChronikFuerDichBox.svelte: each dismiss button is now inside a<form>. The button hasaria-label={m.chronik_mark_read_aria()}— that's correct. But the wrapping<form>is an anonymous interactive region. Given the form wraps both the hidden input and the dismiss button, AT may or may not announce the form boundary. In practice this is minor since the button'saria-labelis sufficient, but addingaria-hidden="true"to the<form>element itself (keeping only the button accessible) would make the AT tree cleaner.5. Positive:
py-3→py-3.5on notification rowsThe diff shows
px-4 py-3changed topx-4 py-3.5on the notification button rows. Going from 12px to 14px vertical padding slightly increases the touch target — positive for the senior audience. Small but appreciated.Nit
6. The unicode escape
↩replaced with literal↩The diff shows
{n.type === 'MENTION' ? '@' : '↩'}changed to{n.type === 'MENTION' ? '@' : '↩'}. Both render identically. The literal↩is more readable in source. No issue.📋 Elicit (Requirements Engineer)
Verdict: ⚠️ Approved with concerns
Reviewing from a requirements completeness perspective — specifically whether the stated goal of "progressive enhancement / JS-off degradation" is actually achieved, and whether edge cases are covered.
Blockers
1. JS-off degradation for the bell dropdown is not achievable — acceptance criterion cannot be met
The PR description states: "JS-off degradation now works via native form POST." This is true for
ChronikFuerDichBoxon the/aktivitaetenpage. However,NotificationDropdown.svelterenders insideNotificationBell.svelte, which is opened by a<button>with a JavaScriptonclickhandler. Without JS, the dropdown never opens, so the form actions inside it are never reachable.This is an inherent architectural constraint — a JavaScript-gated dropdown cannot provide JS-off form degradation for its contents. The PR should not claim JS-off degradation for the bell path; only for the Chronik page path. The acceptance criterion as written is partially misleading.
2. Race condition: what happens if the user clicks dismiss on two notifications in rapid succession?
Both
optimisticMarkReadcalls fire immediately. Bothuse:enhanceform POSTs are submitted to the server. There is no loading/disabled state on the button after submission, so the user can submit the same notification's form multiple times.PATCH /api/notifications/{id}/readis presumably idempotent (marking an already-read notification as read is a no-op), but this is not verified by any test. The UI does not prevent double-submission.Minimum: disable the submit button after first click (standard
use:enhancepattern with asubmittingflag).Suggestions
3. The requirement "mark-all-read clears the badge" is tested server-side but the badge update path is indirect
The badge count lives in
notificationStore.unreadCount, which is updated byoptimisticMarkAllRead(). The form action itself returns{ success: true }and callsupdate({ reset: false, invalidateAll: false }). This means the server's reloaded page data (which would haveunreadNotifications: []) is not re-fetched after mark-all-read. The badge clears only via the optimistic store update, not via a server round-trip.This is intentional and correct — the PR's intent is optimistic updates. But it creates an undocumented invariant: if the optimistic update fires but the server call fails, the badge shows 0 while the server still has unread notifications. This is an accepted trade-off, but it should be documented in a comment next to
invalidateAll: false.4. Missing requirement: what should happen when
dismiss-notificationis called from the bell dropdown on a page other than/aktivitaeten?The bell dropdown exists on every page (it's in the global layout). The form action target is hardcoded to
/aktivitaeten?/dismiss-notification. If a user dismisses a notification from the bell while on/documents/123:use:enhanceintercepts the form, calls the action, andupdate()runs. The user sees no navigation to/aktivitaeten. ✅/aktivitaeten?/dismiss-notification, and SvelteKit redirects back — but there's no redirect in the action, so the user lands on/aktivitaeten. ❌This is the JS-off degradation gap for the bell path (related to blocker #1). Worth documenting as an explicit non-goal: "JS-off dismiss from bell always navigates to Chronik page."
🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
This is a pure frontend refactor — no Docker Compose changes, no CI pipeline changes, no new services, no environment variables. Nothing to flag from an infrastructure perspective.
Observations
1. No infrastructure impact
2. The server action pattern is SSR-friendly
+page.server.tsactions run server-side in the Node adapter. ThecreateApiClient(fetch)call uses the SvelteKit-providedfetch(which forwards cookies and session context), not the browser'sfetch. This is correct for the production deployment topology (Caddy → SvelteKit Node → Spring Boot).3. No
invalidateAll: true— avoids unnecessary backend round-tripsupdate({ reset: false, invalidateAll: false })means after a dismiss or mark-all-read, the page'sload()function is not re-executed. This is correct — it avoids an extra round-trip to the backend on every notification interaction. The load function fetchesunreadNotificationsfrom the backend; skipping that re-fetch and relying on the optimistic store update is the right call for this interaction pattern.4. What's done well
The refactor eliminates two raw
fetch()calls from the client store and routes them through the SvelteKit server layer. This is operationally better: the server-side calls use the authenticated session cookie, are logged server-side (if logging is enabled), and don't expose API endpoints to browser network inspection in the same way. Good hygiene.Pull request closed