feat: chronik mentions should deep-link to the comment, matching the bell dropdown #300
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 missing
Clicking a mention or comment row on
/chronikshould land the user on the specific comment — same UX as clicking a notification in the bell dropdown (wired up in #276). Today the chronik rows land on/documents/{id}with no query params, so the deep-link scroll helper no-ops.Current behaviour by entry point
NotificationBell.svelte:34-37)/documents/{id}?commentId=X&annotationId=Y(both params after #276)ChronikFuerDichBox.svelte:20-22)/documents/{id}?commentId=X(only commentId)scrollToCommentFromQuerybails whenannotationIdis missingChronikRow.svelte:102)/documents/{id}(no params at all)Why they should match
Users don't distinguish between these surfaces in their mental model. A mention is a mention, whether it's surfaced in the bell or the timeline. Three separate UX paths for the same action is inconsistent and makes the chronik feel less useful than it could be.
Root cause
Backend —
ActivityFeedItemDTOdoesn't expose the comment/annotationActivityFeedItemDTO.javahas nocommentIdorannotationIdfields:The audit log payload for
COMMENT_ADDED/MENTION_CREATEDalready carriescommentId(populated byCommentService.logCommentPosted), andAuditLogQueryRepositoryalready pullsMIN(s.payload::text)::jsonb AS payloadin the aggregated CTE. SocommentIdis reachable — it's just not surfaced to the DTO.annotationIdis not in the audit payload today. The cheapest fix: extractcommentIdfrom the payload in SQL, LEFT JOINdocument_comments dc ON dc.id = (payload->>'commentId')::uuid, selectdc.annotation_idalongside. After #276's V51 backfill, every block-comment row has annotation_id populated.Frontend — two places currently build links without these params
ChronikRow.svelte:102— always links to bare/documents/{id}regardless of kind.ChronikFuerDichBox.svelte:20-22— includescommentIdbut omitsannotationId.Proposed change
Backend
ActivityFeedItemDTO:ActivityFeedRowprojection withgetCommentId()/getAnnotationId().AuditLogQueryRepository.findActivityFeedto extractcommentIdfrom the aggregated payload and LEFT JOINdocument_commentsfor the annotation.DashboardService.getActivityto pass them through.npm run generate:api).Frontend
ChronikRow.svelte— whencommentIdandannotationIdare set on the row, build the href with both query params. Keep the bare URL for non-comment kinds (TEXT_SAVED, BLOCK_REVIEWED, etc.).ChronikFuerDichBox.svelte— extendNotificationItem.href()to include&annotationId=...when present (same conditional pattern asNotificationBell.svelte:34-37; can be extracted to a shared helper in$lib/utils/notificationHref.tsso the three call sites stop diverging).Test coverage
AuditLogQueryRepositoryIntegrationTest(or a dashboard-service test) to assert that a seededCOMMENT_ADDEDaudit row produces a DTO with bothcommentIdandannotationIdpopulated.ChronikRow.svelteandChronikFuerDichBox.svelteasserting the href shape for a comment/mention row (with and without annotation id, to cover block comments vs. future cases).frontend/e2e/notification-deep-link.spec.tsor add a chronik-specific spec that clicks a mention row and asserts the scroll + flash + focus contract matches the bell flow.Out of scope
commentIdinto audit payloads older than commitedb4e54d— that was already handled byV50__backfill_comment_audit_events.sql.ChronikRow.svelte:154-163tracked separately).Acceptance criteria
COMMENT_ADDEDorMENTION_CREATEDrow on/chronikopens the document with transcribe mode + edit panel on, scrolls to the comment, flashes the annotation, and focuses the comment article.block: 'center'scroll, existingflashAnnotationIdvocabulary).👨💻 Felix Brandt — Senior Fullstack Developer
Observations
ag.payloadin the aggregated CTE and usesag.payload->>'commentId'in theyouParticipatedsubquery (AuditLogQueryRepository.java:99). Exposing it to the projection is a three-line change.scrollToCommentFromQuerybails when eithercommentIdorannotationIdis missing (deepLinkScroll.ts:19-23). Our fix must populate both — which works for block comments after V51, butDocumentComment.annotationIdisnullfor document-level comments (noblock_id). Those mentions will silently no-op even with this fix.NotificationBell.svelte:34-37,ChronikFuerDichBox.svelte:20-22, and the futureChronikRow.svelte:102. The issue calls the shared helper "nice-to-have" — I disagree.Recommendations
src/lib/utils/notificationHref.ts:AuditLogQueryRepositoryIntegrationTest— seed doc + block +document_comments+COMMENT_ADDEDaudit row, assert DTO has both IDs (extend the pattern atAuditLogQueryRepositoryIntegrationTest.java:64-80).ChronikRow.svelte.spec.ts— three cases: COMMENT_ADDED with both IDs, COMMENT_ADDED with commentId only, non-comment kind.ChronikFuerDichBox.svelte.spec.ts— href shape for block-comment vs document-comment mention.chronik-deep-link.spec.ts(do not cram into notification-deep-link.spec.ts — the two surfaces will evolve independently).LEFT JOIN document_comments dc ON dc.id = (ag.payload->>'commentId')::uuidafter theaggregatedCTE, in the outer SELECT. Keep the projection minimal: justdc.annotation_id AS annotationIdand(ag.payload->>'commentId')::uuid AS commentId.COMMENT_ADDED/MENTION_CREATED. Mark them@Nullablein bothActivityFeedItemDTOandActivityFeedRow, and ensure@Schema(requiredMode = NOT_REQUIRED)so the generated TypeScript type iscommentId?: string | null.commentId/annotationIdparams on navigation regardless of whether scroll succeeded. Current helper already does this viaopts.onStripUrl()— good, keep it.Open Decisions
count > 1on a COMMENT_ADDED session, which comment do we deep-link to? TheMIN(payload::text)::jsonbtrick picks the earliest. For rollups, earliest may not be what the user expects (most recent thread activity is usually the interesting one). Options: (a) pick earliest — current behavior, simplest; (b) switch to latest — requiresMAXaggregation with timestamp tiebreaker; (c) don't deep-link rollups, bare URL only. Leonie's call.🏛️ Markus Keller — Senior Application Architect
Observations
AuditLogQueryRepository.findRolledUpActivityFeedalready crosses a domain boundary: it reads fromnotifications(line 96) to computeyouParticipated. The proposed fix adds a second cross-domain table:document_comments. The query is becoming a cross-cutting projection, not a pure audit query.DashboardService.getActivityalready post-processes rows — it callsDocumentService.getDocumentsByIdsto enrich document titles (DashboardService.java:122). There is a precedent for enriching in Java rather than SQL.document_commentsPK are ~1ms.Recommendations
ActivityFeedRowwithgetCommentId()(from the existingag.payload) but notgetAnnotationId(). Then inDashboardService.getActivity:CommentServiceownsdocument_comments, not the audit query. It matches the pattern already in use for document titles.kind ∈ {COMMENT_ADDED, MENTION_CREATED}. Java records don't express this invariant well, but at minimum: add a@Schema.descriptionnoting "present only for comment/mention events" so OpenAPI consumers know. Optional sealed-interface refactor is overkill for this project size.annotation_idondocument_comments. New code reads existing columns. Good.Open Decisions
🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
commentIdandannotationIdare UUIDs; the document title is already in the feed, so the sensitive field (what the comment says) isn't surfaced.AuditLogQueryRepository.findRolledUpActivityFeeddoes not filter by the user's document permissions. Any authenticated user sees every document's activity in their feed (title, document ID, now also commentId/annotationId). If permission-scoped documents exist, this is already a broader IDOR exposure — but it's not introduced by this issue, so flagging and leaving scope.(ag.payload->>'commentId')::uuid— Postgres does the cast, input is not user-controlled at this point (it's stored audit data). Injection-safe.url.searchParams.get('commentId')→opts.setActiveAnnotationId(annotationId)→ DOM idcomment-${commentId}indeepLinkScroll.ts:37. A user-crafted URL with a malicious value lands in adocument.getElementByIdcall, which cannot execute code. Safe.Recommendations
DashboardService.getActivityshould filter out audit rows for documents the current user cannot read. This issue widens the information surface incrementally — if the principle "user can see what they have access to" is supposed to hold, now is a cheap time to file the spinoff. I can draft the issue if useful.scrollToCommentFromQuery: even though it's safe today, add a UUID regex check beforegetElementById— defense in depth in case a future use of the param does reach a sink that matters (e.g. a log line, an API call, HTML attribute construction):/documents/{id}?commentId=<random-uuid>&annotationId=<random-uuid>for a comment that isn't theirs — assert no scroll, no flash, no error surfaced to console. Confirms fail-safe behavior.Open Decisions (none)
🧪 Sara Holt — Senior QA Engineer
Observations
AuditLogQueryRepositoryIntegrationTest.findRolledUpActivityFeed_returnsAnnotationEntryonly coversANNOTATION_CREATED, notCOMMENT_ADDED. This issue's regression risk is in the SQL, so this is the gap to close first.notification-deep-link.spec.tsalready proves the bell flow at 320 and 1440 with an axe check. The chronik flow is a parallel surface — its tests should mirror this spec's structure so the two contracts are visible together.ChronikRow.svelte.spec.tsandChronikFuerDichBox.svelte.spec.tsexist — extend them, don't duplicate.Recommendations
Test strategy, layer by layer:
Integration (
AuditLogQueryRepositoryIntegrationTest) — one new test per case:findRolledUpActivityFeed_returns_commentId_and_annotationId_for_block_comment: seed users + doc +transcription_blocks+document_commentswithannotation_idset +audit_log COMMENT_ADDEDwithcommentIdpayload → assert projection fields populated.findRolledUpActivityFeed_returns_null_annotationId_for_document_level_comment: same butdocument_comments.annotation_id = NULL→ assertcommentIdpopulated,annotationIdnull.findRolledUpActivityFeed_returns_null_both_for_non_comment_kind: existingANNOTATION_CREATEDtest extended to assert the two new fields are null.Component (
ChronikRow.svelte.spec.ts):commentId./documents/{id}.Component (
ChronikFuerDichBox.svelte.spec.ts):E2E (
frontend/e2e/chronik-deep-link.spec.ts) — new file, structure mirroringnotification-deep-link.spec.ts:beforeAll: create doc + block + post block comment → chronik row exists./chronik, click the row, assert transcribe mode entered, comment visible, URL stripped, flash visible (or instant with reduced-motion)./chronikand on the deep-linked document view.Do NOT extend
notification-deep-link.spec.tswith chronik cases — the two surfaces have separate failure modes (bell usesnotificationStore.markRead, chronik uses plain<a href>). Shared fixtures, separate specs.[data-hydrated]on the document page after navigation, same as the existing spec. Don't useThread.sleep-equivalentawait page.waitForTimeout.Open Decisions
comment added × 3, a single click can only go to one comment. Which one dictates what the backend's rollup aggregation emits. Sara's test depends on Leonie's UX decision (Felix also flagged this).🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
block: 'center',focus({preventScroll: true}), flash the annotation, strip URL. The issue explicitly requires parity — good.ChronikRow.sveltecurrently wraps the entire row in an<a>— keyboard Enter will trigger navigation the same as click, which is correct for deep-links. ✅annotation_id = NULL(document-level comments) will arrive with commentId only. The helper bails atdeepLinkScroll.ts:21-23. From the user's perspective: they click the row, land on the document page, nothing happens. That is a worse outcome than landing on the bare document.Recommendations
scrollToCommentFromQueryto degrade gracefully for commentId-only (no annotationId):setActiveAnnotationId(no annotation to activate).comment-{id}, focus, no flash (no annotation to flash).count > 1, link to the most recent comment in the session. Rationale: if someone added 3 comments over 30 minutes, the user's mental model after seeing the chronik row is "what's new here" — latest is newest. Technically this meansMAX(happened_at)in the aggregation, with the corresponding payload lookup. If that's too complex, fall back to option (c): no deep-link on rollups, bare URL only. Do not pick earliest — it's the stalest possible choice.<article>should have an accessible name (e.g.aria-label="Kommentar von Max · 14:23") so arriving via focus makes sense aurally. Verifycomment-{id}element has this.'instant'scroll + skips flash animation. Chronik flow must do the same. The helper already readsprefersReducedMotion— just wire it through on the chronik entry path.<a>so this is fine. ✅flashAnnotationIdmechanism must trigger on the annotation in the PDF and highlight the comment itself viafocus-visiblering. The bell currently flashes the annotation; verify the comment article also gets a brief visual cue on arrival (focus ring is likely sufficient).Open Decisions
⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
document_commentsadds ~40 indexed PK lookups for a 40-row feed. Negligible on the current VPS (CX32, 4 vCPU).frontend/e2e/) is currently not in CI perfrontend/e2e/CLAUDE.md. The new chronik-deep-link spec will only run locally unless CI is extended.Recommendations
infra/gitea/workflows/ci.yml(uses Docker Compose, runs on merge). Scope this issue to just the two specs.npm run generate:apirequires backend running with--spring.profiles.active=dev. Mention this explicitly in the PR checklist so the TypeScript types infrontend/src/lib/generated/api.tsare committed — easy to miss when working in the backend-first part of the plan.@TimedonDashboardController.getActivityif not already present — once the LEFT JOIN lands, we have a baseline. Same separate-issue applies if wanted.notificationHref.ts) is ~15 lines gzipped. Zero concern.@Nullable, frontend falls back to the bare URL when they're null. If the backend rolls back while the frontend is updated, old JSON doesn't include the fields,undefinedflows through, bare URL is built. Forward-compatible. ✅Open Decisions (none)
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Architecture
annotationId: SQL JOIN vs. service-layer lookup — Option A: addLEFT JOIN document_comments dc ON dc.id = (ag.payload->>'commentId')::uuidinsideAuditLogQueryRepository.findRolledUpActivityFeedand selectdc.annotation_id. Cheap (one query), but stacks another cross-domain join onto a CTE that already referencesnotificationsandusers. Option B: return justcommentIdfrom SQL, then inDashboardService.getActivitybatch-fetch annotation IDs via a newCommentService.findAnnotationIdsByIds(commentIds)method. Respects the module boundary (CommentServiceownsdocument_comments), matches the existingdocumentService.getDocumentsByIdspattern. Adds one round-trip (~1ms for PK lookups). (Raised by: Markus — recommends B)UX
comment added × 3, a single click can only link to one comment. Options: (a) earliest in session — what the currentMIN(payload::text)::jsonbaggregation happens to produce, zero extra work, but shows the stalest choice; (b) latest in session — requires changing the aggregation to pick the MAX-timestamped row's payload, matches "what's new here" user intent; (c) no deep-link on rollup — bare/documents/{id}, user navigates manually. Leonie recommends (b), fallback (c); Felix and Sara are blocked on this for their TDD plan. (Raised by: Felix, Sara, Leonie)🎯 Discussion Resolutions
After reviewing the persona feedback with the user, these are the authoritative decisions. The
implementskill should treat this comment as the design source alongside the original issue.Theme 1 — Enrichment approach
ActivityFeedRowgains onlygetCommentId()(extracted from the existingag.payload). A newCommentService.findAnnotationIdsByIds(List<UUID>)batch-loads annotation IDs.DashboardService.getActivityapplies the lookup to each row.AuditLogQueryRepositoryfree of another cross-domain JOIN. Matches the existingDocumentService.getDocumentsByIdspattern already used for titles. ~1ms overhead (PK lookups) on a 40-row feed.Theme 2 — Rollup deep-link target
MIN(payload::text)::jsonbtoFIRST_VALUE(payload) OVER (PARTITION BY ... ORDER BY happened_at DESC)so the surfacedcommentIdis the newest event in the rollup.Theme 3 — Document-level comments (annotation_id = NULL)
scrollToCommentFromQuerycontinues to require bothcommentIdandannotationId.CommentControlleronly exposes block-comment and reply endpoints; both service methods always setannotationId; V51 backfilled historical rows. There are no code paths that produceannotation_id = NULL. TighteningDocumentComment.annotationIdto NOT NULL is a separate concern, not this PR.Theme 4 — Shared href helper
src/lib/utils/commentDeepLink.ts:NotificationBell.svelte,ChronikFuerDichBox.svelte, andChronikRow.svelte. Gets its own unit test.Theme 5 — DTO shape
ActivityFeedItemDTOgains:ActivityFeedRowgains onlygetCommentId();annotationIdis resolved in Java. PR checklist includesnpm run generate:api(backend must run with--spring.profiles.active=dev).Theme 6 — Test strategy
AuditLogQueryRepositoryIntegrationTest):findRolledUpActivityFeed_returns_commentId_for_COMMENT_ADDED— seed doc + block +document_comments+ COMMENT_ADDED audit row → assertcommentIdpopulated.findRolledUpActivityFeed_returns_null_commentId_for_non_comment_kind— extend the existing ANNOTATION_CREATED case to assertcommentIdnull.findRolledUpActivityFeed_rollup_commentId_points_to_latest_event_in_session— seed three COMMENT_ADDED rows in one session → assert the rolled-up row'scommentIdmatches the most recent event.CommentService.findAnnotationIdsByIdsunit test (happy path + empty input + unknown IDs).commentDeepLink.spec.ts(new) — helper branching.ChronikRow.svelte.spec.ts— three cases: COMMENT_ADDED (both params), TEXT_SAVED (bare), rollup with count>1 (points to latest-event IDs).ChronikFuerDichBox.svelte.spec.ts— one case: mention with both IDs → both params.frontend/e2e/chronik-deep-link.spec.ts):beforeAll: seed doc + block + block comment.#comment-{id}visible, URL params stripped.[data-hydrated]. NowaitForTimeout.notification-deep-link.spec.ts.Theme 7 — Accessibility & animation parity
prefersReducedMotionfor instant scroll, no new animation styles, no aria-label additions. 44px touch target already met by the full-row<a>on ChronikRow.Theme 8 — Security hardening
scrollToCommentFromQuery.getElementByIdis a safe sink today; defensive validation at the source drifts out of sync with actual threats. Validate at new sinks when/if they're introduced.Theme 9 — Spinoff issues
@Timedon feed endpoint) are logged in the review thread for future reference but not urgent enough to file.These resolutions are the authoritative design for implementation. The
implementskill will read this alongside the original issue.