bug: notification deep-link does not scroll to comment on document detail page #276
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?
What's broken
Clicking a notification (bell dropdown or notifications page) navigates to
/documents/{id}?commentId={uuid}but nothing happens on the document detail page — no scroll, no highlight, no annotation panel opened. The link lands on the page and the query params are silently ignored.This was working at some point and was lost during the component split/refactoring.
Root cause analysis
URL generation — partially broken
NotificationBell.svelte:35andnotifications/+page.svelte:53both try to includeannotationIdin the URL when it is present on the notification object:However, in practice
notification.annotationIdisnulleven for annotation comments, so the URL only ever contains?commentId={uuid}. The upstream cause (whyannotationIdisn't reaching the notification) is a separate investigation; for the purposes of this fix, treatcommentIdas the only reliable param.Since all comments are annotation comments, a
commentIdalways resolves to exactly one annotation. The annotation can be looked up server-side from the comment.Consuming side — entirely missing ❌
documents/[id]/+page.sveltehas no code that reads?commentIdfromurl.searchParams. The page receives the param in the URL but ignores it.DOM anchor — missing ❌
CommentMessage.svelterenders each comment as<div role="article" class="flex gap-2">with noidattribute. Even if the page read?commentId, there is nothing to scroll to.What needs to be implemented
1. Backend: endpoint to resolve a comment's annotation
Add
GET /api/documents/{documentId}/comments/{commentId}(or a dedicated context endpoint) that returns at minimum the comment'sannotationId. This is needed so the detail page can open the right annotation panel withoutannotationIdin the URL.Alternatively, extend the existing notification DTO/API to reliably return
annotationId— but the frontend fix should not depend on this being fixed first.2. Add
idanchor toCommentMessage.svelte3. Read
?commentIdand resolve annotation indocuments/[id]/+page.svelteonMount4. Ordering: annotation panel must be open before scroll
The
CommentThreadlives inside the annotation detail panel.activeAnnotationIdmust be set and the panel must finish rendering beforescrollIntoViewis called. A singletick()may not be sufficient if the panel has async rendering — arequestAnimationFrameinside the tick may be needed.Scope
frontend/src/lib/components/CommentMessage.svelte— addid="comment-{message.id}"to the article wrapperfrontend/src/routes/documents/[id]/+page.svelte— read?commentIdinonMount, fetch comment to resolveannotationId, activate annotation panel, scroll to comment, clean up URLbackend/CommentController.java— addGET /api/documents/{documentId}/comments/{commentId}read endpoint (or extend existing notification API)👨💻 Felix Brandt — Senior Fullstack Developer
Observations
handleAnnotationClickatdocuments/[id]/+page.svelte:220-248already does activate-panel + flash +requestAnimationFrame+scrollIntoView. That is the pattern the?commentIdhandler should reuse — not duplicate.prefers-reduced-motionis already honored atdocuments/[id]/+page.svelte:41-43and used to choosescrollBehaviorand flash duration. The new path must reuse the same derivation.notifications/+page.svelte:53— that file does not exist in the current repo. URL generation lives only inNotificationBell.svelte:34-37. If a standalone notifications page is intended separately, scope this fix to the bell dropdown only or clarify the path.CommentMessage.svelte:35confirmed:<div role="article" class="flex gap-2">has noid. Addingid="comment-{message.id}"is correct.Recommendations
scrollToCommentFromQuery(commentId)at the top of the<script>. It fetches the comment, setsactiveAnnotationId, waits onerequestAnimationFrameaftertick(), scrolls, flashes the comment, then callsreplaceState(page.url.pathname, {}). Reuse thescrollBehavioralready derived fromprefersReducedMotion.import { page } from '$app/state'(Svelte 5) — the deprecated store form should not creep in.scrollIntoViewis called on#comment-{id}when?commentIdis present and backend returns 200?commentIdis absentreplaceStatestripscommentIdafter handling'instant'behaviorDocumentCommententity from the new GET, not a{annotationId}projection. Clients already handle the shape; a one-field DTO adds coupling for no gain.Open Decisions
CommentController.java:29-117). The issue assumes annotation scope. A block-comment deep-link would need transcribe mode + block activation; a document-level comment has no annotation to open. Options: (a) scope this PR to annotation comments only, explicit no-op + TODO for the other two; (b) handle all three scopes now. (a) ships quickly with a known gap; (b) meaningfully larger.🏛️ Markus Keller — Application Architect
Observations
notification.annotationIdis null even for annotation comments). The frontend fix is clean and isolated, but the underlying data bug remains — every notification click-through will make one extra round-trip forever.CommentController.java:29-117has no@RequirePermissionon the GET endpoints. Authentication is enforced by Spring Security's blanket.authenticated(), but there is no declarative per-document authorization at the controller layer. Any new GET should match the existing pattern, not invent a new convention mid-PR.notifications/+page.svelte:53— that path doesn't exist in the repo. Worth clarifying before implementation to avoid confusion.Recommendations
GET /api/documents/{documentId}/comments/{commentId}. It reads symmetrically against the other three GETs inCommentController(document, annotation, block). Keeping{documentId}in the path gives Nora a clean document-match guard to add.DocumentCommententity. Single-field projection DTOs create coupling that rots when the frontend wants one more field.Open Decisions
annotationIdon notification creation, backfill existing rows in a small migration. The frontend fix drops the new endpoint entirely — just reads?annotationIdfrom the URL. Cost: touches the notification write path and adds a one-time backfill.My recommendation is (A). The fix is small enough that doing it once is cheaper than maintaining two paths. But it expands this PR's scope — your call whether to do it here or split.
🛡️ Nora "NullX" Steiner — Application Security Engineer
Observations
CommentController.javahave no@RequirePermission. Global.authenticated()is enforced at the Spring Security layer, but per-document authorization is not visible at the controller. Any authenticated user can callGET /api/documents/{docId}/commentstoday for any document. Confirm this is intentional for the family-internal trust model.?commentId=<uuid>will be followed by whoever opens it. If the link is forwarded or the email client prefetches it, the recipient must not learn anything they couldn't learn without the URL.Recommendations
GET /api/documents/{docA}/comments/{commentIdFromDocB}leaks comment B's annotation id — weak IDOR. Return 404 (not 200, not 403) when the comment'sdocumentIddoes not equal the pathdocumentId.@WithAnonymousUser→ 401UUIDcoercion; confirm it's a 400, not a 500)WARNwith a structured tag (event=comment_lookup_failed,commentId={}) when the lookup misses. Pattern:logger.warn("Stale notification deep-link: commentId={}", commentId)— parameterized form only, never+.commentIdvalue is a raw string frompage.url.searchParams.get(). Template-literal concatenation into the fetch URL is safe as long as the handler rejects malformed UUIDs. Don't build the URL viaencodeURIComponent— unnecessary, and hides malformed input you want the backend to reject loudly.Open Decisions
@RequirePermissionon existing GETs is worth a dedicated issue — out of scope here but I'd tag it.🧪 Sara Holt — QA & Test Strategist
Observations
activeAnnotationIdset → panel renders → scroll. That's a known brittle surface — testing it wrong produces a flaky suite.handleAnnotationClickatdocuments/[id]/+page.svelte:242already combinesrequestAnimationFramewithscrollIntoView. Tests should assert the same timing pattern, not reinvent it.Recommendations
Backend —
@WebMvcTest(CommentController.class):getCommentById_returns_comment_with_annotationId_when_foundgetCommentById_returns_404_when_commentId_does_not_existgetCommentById_returns_404_when_documentId_does_not_match(per Nora)getCommentById_returns_401_when_unauthenticatedgetCommentById_returns_400_when_commentId_is_not_a_valid_uuidFrontend —
vitest-browser-sveltetests ondocuments/[id]/+page.svelte:scrolls_to_comment_when_commentId_param_is_presentno_scroll_attempted_when_commentId_param_absentgraceful_noop_when_backend_returns_404_for_commentIdstrips_commentId_from_url_after_handlinghonors_prefers_reduced_motion_for_scroll_behaviorMock
fetchat the module boundary. UsewaitForon the scroll spy — neversetTimeoutin the assertion.E2E — Playwright (
frontend/e2e/):?commentId=X→ annotation panel open → target comment visible in viewport + flash visible. Run at 320px AND 1440px; the 320px layout is the one most likely to hide the comment behind the PDF strip.id, newtabindexif Leonie's focus proposal lands. Don't let accessibility regress silently.Open Decisions
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
prefers-reduced-motionis already plumbed indocuments/[id]/+page.svelte:41-43and used for both the scroll behavior ('instant'vs'smooth') and flash duration (2000 ms vs 1500 ms) inhandleAnnotationClick. The new code path must reuse that same derivation — introducing a second motion policy breaks the feel.CommentMessage.svelte:35has no visible focus indicator today. Adding justid="comment-{message.id}"doesn't help screen readers or keyboard users land on the comment — it only helpsscrollIntoView.md(seedocuments/[id]/+page.svelte:375). After scroll, the comment can end up partially hidden behind the 70px collapsed PDF strip.Recommendations
flashAnnotationIdvocabulary. After the scroll, trigger the existing flash animation on the comment for 1500 ms (2000 ms with reduced motion). Users already recognize this as "here's where you landed" from annotation clicks — don't teach them a second visual.tabindex="-1"alongside the newidonCommentMessage.svelte. Then call.focus()afterscrollIntoView. This moves screen-reader focus so the comment announces itself, without polluting the normal tab order. Pair withfocus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2+outline-noneon the same element — visible ring for sighted keyboard users.block: 'center'notblock: 'nearest'for the scroll. At 320px the collapsed PDF strip can clip'nearest'positioning;'center'guarantees vertical centering regardless of surrounding chrome.aria-liveto the comment list container — the list rerenders frequently during editing and would over-announce.Open Decisions
focus-visible, so mouse users never see it. But it's a judgment call on UX weight.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
onMountbranch, one attribute onCommentMessage.svelte. No Compose changes, no Caddy rules, no CI steps, no env vars, no migration (unless Markus's upstream-fix option is chosen).Recommendations
WARNlog on the 404 path with structured fields:logger.warn("Stale notification deep-link: commentId={}", commentId). Loki/Grafana picks it up automatically once the message format is grep-able — no new dashboard required.annotationIdon notification creation + backfill migration), the backfill SQL belongs in a new Flyway migration file — don't modify existing migrations. Single UPDATE overnotificationsjoined to comments; runs in a few hundred ms on the family dataset./api/*which is already proxied.Open Decisions
🗳️ Decision Queue — Action Required
3 decisions need your input before implementation starts.
Architecture
notification.annotationIdis null even for annotation comments. The proposed fix adds a new GET endpoint to route around it. Options:annotationIdon notification creation + one-time backfill migration. Drop the new endpoint entirely. Frontend reads?annotationIdfrom the URL directly. Cleaner long-term; expands this PR's scope.(Raised by: Markus)
Scope
(Raised by: Felix)
Accessibility
tabindex="-1"to the comment and calls.focus()after scroll; afocus-visiblering appears only for keyboard users. ~5 extra lines, better for screen-reader and keyboard users.(Raised by: Leonie)
🎯 Discussion Resolutions
After reviewing the persona feedback with the user, here are the agreed decisions. These supersede the original issue body where they conflict, and act as the authoritative design for implementation.
Important domain context surfaced during discussion: comments now exist only on transcription blocks. The document-level and annotation-level comment paths are dead code that this PR cleans up.
Theme 1 — Root-cause strategy
Decision: Fix upstream. In
CommentService.postBlockComment, setcomment.annotationId = block.getAnnotationId()at creation. Add a Flyway migration to backfill existing block comments from their blocks. The new GET endpoint proposed in the issue body is dropped entirely.Rationale: The data model already has
DocumentComment.annotationId;postBlockCommentsimply forgot to populate it. Fixing once is cheaper than a permanent workaround endpoint and one extra round-trip per notification click.Theme 2 — Dead-code cleanup (bundled in this PR)
Decision: Delete all document-level and annotation-level comment code paths.
CommentController.java:29-86— delete six handlers:getDocumentComments,postDocumentComment,replyToDocumentComment,getAnnotationComments,postAnnotationComment,replyToAnnotationComment.CommentService— deletegetCommentsForDocument,getCommentsForAnnotation, and the 2-argpostComment(documentId, annotationId, …)overload. KeepreplyToComment(block-reply path uses it).DocumentComment.annotationIdcolumn stays; its meaning is now "the block's annotation" for block comments.Rationale: Zero frontend consumers of the dead endpoints were found. Shipping cleanup with the bug fix is cheaper than a separate PR.
Theme 3 — collapsed
No new backend endpoint needed (follows from Theme 1).
Theme 4 — Authorization on remaining block-comment GETs
Decision: No change. Every authenticated user is allowed to read in this family-internal model. The absence of
@RequirePermissionis intentional.Rationale: Confirmed with user.
Theme 5 — Frontend handling flow
Decision: Extract a helper
scrollToCommentFromQuery(commentId)indocuments/[id]/+page.svelte. No backend fetch — the URL now reliably carries both?commentId=…&annotationId=…after the Theme 1 upstream fix, and DOM id is the source of truth for "does this comment still exist?"Flow:
import { page } from '$app/state'; bail early ifcommentIdis absent.transcribeModeis off, set it totrueandawait loadTranscriptionBlocks().activeAnnotationIdfrom the URL'sannotationId.await tick()+ onerequestAnimationFrame— mirrors the existinghandleAnnotationClick:242-247pattern.document.getElementById('comment-' + commentId)?.scrollIntoView({ behavior: scrollBehavior, block: 'center' })..focus({ preventScroll: true })on the same element.flashAnnotationIdflash (1500 ms / 2000 ms reduced-motion).replaceState(page.url.pathname, {})to strip both query params.Reuse points:
prefersReducedMotionderivation at lines 41-43 — unchanged.flashAnnotationIdvocabulary — no new animation.block: 'center'chosen so the 320 px collapsed PDF strip can't clip the landing.Rationale: Using the URL as state and the DOM as truth removes an entire class of failure (stale comments silently no-op) and eliminates a round-trip. The helper composes with the already-battle-tested
handleAnnotationClicktiming.Theme 6 — DOM anchor and focus behavior
Decision: Ship flash + focus transfer.
On
CommentMessage.svelte:35, change the wrapper to:After scroll, call
.focus({ preventScroll: true })on the element. Do not addaria-liveto the comment list container — it rerenders during editing and would over-announce.Rationale: Flash covers sighted mouse users.
tabindex="-1"+.focus()covers screen readers and sighted keyboard users. Thefocus-visiblering only appears for keyboard navigation — mouse users see nothing extra.Theme 7 — Testing strategy
Backend (unit/slice):
CommentServiceTest.postBlockComment_sets_annotationId_from_block— core upstream fixNotificationServiceTest.createsNotification_with_annotationId_populated_from_block_comment— regression pin on the notification path@SpringBootTestTestcontainers chainFrontend (
vitest-browser-svelteondocuments/[id]/+page.svelte):scrolls_to_comment_and_focuses_it_when_commentId_and_annotationId_are_in_urlenters_transcribe_mode_when_query_params_present_and_mode_is_offno_scroll_attempted_when_commentId_absentgraceful_noop_when_target_comment_id_is_not_in_DOMstrips_both_commentId_and_annotationId_from_url_after_handlinghonors_prefers_reduced_motionMock
fetchat the module boundary. UsewaitForon scroll/focus spies — neversetTimeout.E2E (Playwright):
#comment-{id}in viewport, flash visible, focus on article.axe-corecheck after focus lands — guards the newtabindex+focus-visiblering against a11y regressions.These resolutions are the authoritative spec. The
implementskill will read this comment alongside the original issue body.✅ Implementation complete
All tasks from the approved plan are green. Branch:
feat/issue-276-notification-deep-link-scroll.Commits (in order)
46588522— fix(comment): populate annotationId on block comments from the block13732ab9— fix(db): V51 backfills annotation_id on block comments and notificationsbc69e8ff— refactor(comment): drop dead document and annotation comment APIs251eb9c3— feat(frontend): add scrollToCommentFromQuery helper for notification deep-link20ae85f8— feat(comment): expose comment id + focus ring on CommentMessage wrappere22265f5— feat(document-detail): wire notification deep-link scroll in onMount567faee3— test(e2e): notification deep-link scrolls to target commentTest matrix
./mvnw test), build green (./mvnw clean package -DskipTests)npm run test)What's next
deliver-issuetakes over: push branch, open PR, run the review+fix loop.