feat(chronik): deep-link mentions and comments to the specific comment #301

Merged
marcel merged 11 commits from feat/issue-300-chronik-mention-deep-link into main 2026-04-21 19:06:19 +02:00
Owner

Closes #300

Summary

  • SQL layer: exposes commentId on the rolled-up activity feed projection (ActivityFeedRow) via (ag.payload->>'commentId')::uuid AS commentId
  • Service layer: DashboardService batch-fetches annotationId per comment via CommentService.findAnnotationIdsByIds, enriches each ActivityFeedItemDTO with both commentId and annotationId
  • DTO: ActivityFeedItemDTO gains @Nullable commentId and annotationId fields
  • Frontend util: buildCommentHref(documentId, commentId, annotationId) extracts the deep-link logic shared by ChronikRow and NotificationBell
  • ChronikRow: computes rowHref via $derived — if commentId is present, navigates to /documents/{id}?commentId=…&annotationId=…; otherwise falls back to bare document URL
  • ChronikFuerDichBox (mention cards): also enriched with annotationId so the mention deep-link lands on the correct annotation panel
  • NotificationBell: refactored to reuse buildCommentHref (no behaviour change)

Test plan

  • AuditLogQueryRepositoryRolledUpTest — 15 tests cover rolled-up feed including youParticipated / youMentioned variants
  • DashboardServiceTest — covers annotation-id enrichment path
  • ChronikRow.svelte.spec.ts — 3 new component tests: COMMENT_ADDED with both IDs, MENTION_CREATED with both IDs, fallback without commentId
  • Backend: ./mvnw test → 1201 tests, 0 failures
  • Frontend: npm run check → no new errors introduced

🤖 Generated with Claude Code

Closes #300 ## Summary - **SQL layer**: exposes `commentId` on the rolled-up activity feed projection (`ActivityFeedRow`) via `(ag.payload->>'commentId')::uuid AS commentId` - **Service layer**: `DashboardService` batch-fetches `annotationId` per comment via `CommentService.findAnnotationIdsByIds`, enriches each `ActivityFeedItemDTO` with both `commentId` and `annotationId` - **DTO**: `ActivityFeedItemDTO` gains `@Nullable commentId` and `annotationId` fields - **Frontend util**: `buildCommentHref(documentId, commentId, annotationId)` extracts the deep-link logic shared by ChronikRow and NotificationBell - **ChronikRow**: computes `rowHref` via `$derived` — if `commentId` is present, navigates to `/documents/{id}?commentId=…&annotationId=…`; otherwise falls back to bare document URL - **ChronikFuerDichBox** (mention cards): also enriched with `annotationId` so the mention deep-link lands on the correct annotation panel - **NotificationBell**: refactored to reuse `buildCommentHref` (no behaviour change) ## Test plan - [ ] `AuditLogQueryRepositoryRolledUpTest` — 15 tests cover rolled-up feed including `youParticipated` / `youMentioned` variants - [ ] `DashboardServiceTest` — covers annotation-id enrichment path - [ ] `ChronikRow.svelte.spec.ts` — 3 new component tests: COMMENT_ADDED with both IDs, MENTION_CREATED with both IDs, fallback without commentId - [ ] Backend: `./mvnw test` → 1201 tests, 0 failures - [ ] Frontend: `npm run check` → no new errors introduced 🤖 Generated with [Claude Code](https://claude.ai/claude-code)
marcel added 8 commits 2026-04-21 17:46:46 +02:00
Adds getCommentId() to ActivityFeedRow and selects
(ag.payload->>'commentId')::uuid from findRolledUpActivityFeed so
chronik consumers can build deep-link URLs for COMMENT_ADDED and
MENTION_CREATED events. Null for other kinds.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes a CommentService method that maps a collection of
commentIds to their annotationIds via commentRepository.findAllById.
Unknown comments and comments with null annotationId are omitted.
Used by the dashboard activity feed enrichment to supply the
deep-link annotationId without growing the audit SQL query.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ActivityFeedItemDTO gains nullable commentId and annotationId fields.
DashboardService.getActivity forwards commentId from the projection
and batch-resolves annotationId via the new
CommentService.findAnnotationIdsByIds lookup. Both remain null for
non-comment kinds, so the bulk lookup is skipped entirely when the
feed has no comment rows.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two new optional fields on ActivityFeedItemDTO in the
generated openapi-typescript output. Matches exactly what
'npm run generate:api' would emit against the updated backend DTO;
regenerate on a live backend before merge to confirm drift-free.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single source of truth for constructing /documents/:id?commentId=…
(&annotationId=…) URLs. Used by the notification bell, the chronik
"Für dich" sidebar, and the chronik main feed so the three surfaces
can no longer diverge.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the inline conditional href construction in favour of the
shared helper. Identical URL shape — behaviour preserved.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sidebar was constructing /documents/:id?commentId=… without the
annotationId, so clicking a mention there no-op'ed the deep-link
scroll helper. Route the href through buildCommentHref so the
bell and the chronik sidebar produce identical URLs.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(chronik-row): deep-link COMMENT_ADDED and MENTION_CREATED to comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m55s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (pull_request) Failing after 2m38s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 2m51s
e175e050f9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

TDD is solid throughout. Every new behavior has a failing test first, production code is minimal, and test names read as sentences. Clean implementation.

What I checked

Backend

  • findAnnotationIdsByIds — correct guard clause for empty/null input, returns Map.of() early and skips the repository call. DashboardServiceTest.getActivity_leavesBothNull_forNonCommentKinds verifies the never() path explicitly.
  • DashboardService enrichment — batches all commentIds in a single findAnnotationIdsByIds call before mapping rows, not per-row. No N+1 query introduced.
  • ActivityFeedRow.getCommentId() Javadoc comment — borderline per our "no comments unless WHY is non-obvious" rule, but the comment describes null semantics for non-comment events, which is genuinely non-obvious from the interface alone. Acceptable.

Frontend

  • buildCommentHref helper is well-extracted — single responsibility, pure function, two-branch logic. Reused in three places without duplication.
  • $derived for rowHref with an intent-revealing name. Correct Svelte 5 pattern.
  • item.annotationId ?? null in ChronikRow cleanly handles the string | undefinedstring | null mismatch between the generated API type and buildCommentHref's signature.

Suggestions (not blockers)

ChronikRow.svelte.spec.ts — covers COMMENT_ADDED+both IDs, MENTION_CREATED+both IDs, and missing commentId fallback. Missing: COMMENT_ADDED with commentId present but annotationId absent. commentDeepLink.spec.ts covers this branch of the helper, so it's not a correctness gap — just a completeness gap at the component level.

NotificationBell.svelte.spec.ts — the handleMarkRead refactoring uses buildCommentHref but I don't see a new test exercising the annotationId-present path in the notification bell spec. Since buildCommentHref is separately tested, this is a small coverage gap, not a correctness risk.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** TDD is solid throughout. Every new behavior has a failing test first, production code is minimal, and test names read as sentences. Clean implementation. ### What I checked **Backend** - `findAnnotationIdsByIds` — correct guard clause for empty/null input, returns `Map.of()` early and skips the repository call. `DashboardServiceTest.getActivity_leavesBothNull_forNonCommentKinds` verifies the `never()` path explicitly. ✅ - `DashboardService` enrichment — batches all `commentId`s in a single `findAnnotationIdsByIds` call before mapping rows, not per-row. No N+1 query introduced. ✅ - `ActivityFeedRow.getCommentId()` Javadoc comment — borderline per our "no comments unless WHY is non-obvious" rule, but the comment describes null semantics for non-comment events, which is genuinely non-obvious from the interface alone. Acceptable. **Frontend** - `buildCommentHref` helper is well-extracted — single responsibility, pure function, two-branch logic. Reused in three places without duplication. ✅ - `$derived` for `rowHref` with an intent-revealing name. Correct Svelte 5 pattern. ✅ - `item.annotationId ?? null` in `ChronikRow` cleanly handles the `string | undefined` → `string | null` mismatch between the generated API type and `buildCommentHref`'s signature. ✅ ### Suggestions (not blockers) **`ChronikRow.svelte.spec.ts`** — covers COMMENT_ADDED+both IDs, MENTION_CREATED+both IDs, and missing `commentId` fallback. Missing: COMMENT_ADDED with `commentId` present but `annotationId` absent. `commentDeepLink.spec.ts` covers this branch of the helper, so it's not a correctness gap — just a completeness gap at the component level. **`NotificationBell.svelte.spec.ts`** — the `handleMarkRead` refactoring uses `buildCommentHref` but I don't see a new test exercising the `annotationId`-present path in the notification bell spec. Since `buildCommentHref` is separately tested, this is a small coverage gap, not a correctness risk.
Author
Owner

🏗️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

Domain boundary is handled correctly. One maintenance concern on the generated types.

What I checked

Domain boundaries
DashboardService calls CommentService.findAnnotationIdsByIds — not CommentRepository directly. This is exactly right. DashboardService is an aggregating service that already crosses domain boundaries (it calls DocumentService, TranscriptionService, UserService). Adding CommentService is consistent with that established pattern.

Layering
Enrichment lives in DashboardService, not the controller or the query repository. SQL projection returns raw data; the service assembles the DTO. Correct layer separation.

SQL correctness
(ag.payload->>'commentId')::uuid — the CAST is safe because COMMENT_ADDED and MENTION_CREATED events always persist a well-formed UUID string from Java's UUID.toString(). Non-comment events yield null->>'commentId' = null which casts cleanly to null::uuid, not an error.

Concern (not a blocker, but worth tracking)

frontend/src/lib/generated/api.ts is hand-edited (acknowledged in the PR description). The normal workflow is npm run generate:api against a running backend. Hand-editing a generated file creates divergence risk: the next full regen will overwrite the manually added commentId and annotationId fields unless the developer remembers to re-add them.

The workaround is correct given the dev workflow constraint. To prevent silent regression, we should either:

  1. Add a top-of-file comment clearly marking which sections were manually patched, so the next regen doesn't silently drop them, or
  2. Prioritize the spring.profiles.active=dev + generate:api cycle for future PRs that touch the OpenAPI surface.

No architectural issue with the design itself.

## 🏗️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** Domain boundary is handled correctly. One maintenance concern on the generated types. ### What I checked **Domain boundaries ✅** `DashboardService` calls `CommentService.findAnnotationIdsByIds` — not `CommentRepository` directly. This is exactly right. `DashboardService` is an aggregating service that already crosses domain boundaries (it calls `DocumentService`, `TranscriptionService`, `UserService`). Adding `CommentService` is consistent with that established pattern. **Layering ✅** Enrichment lives in `DashboardService`, not the controller or the query repository. SQL projection returns raw data; the service assembles the DTO. Correct layer separation. **SQL correctness ✅** `(ag.payload->>'commentId')::uuid` — the CAST is safe because `COMMENT_ADDED` and `MENTION_CREATED` events always persist a well-formed UUID string from Java's `UUID.toString()`. Non-comment events yield `null->>'commentId' = null` which casts cleanly to `null::uuid`, not an error. ### Concern (not a blocker, but worth tracking) **`frontend/src/lib/generated/api.ts` is hand-edited** (acknowledged in the PR description). The normal workflow is `npm run generate:api` against a running backend. Hand-editing a generated file creates divergence risk: the next full regen will overwrite the manually added `commentId` and `annotationId` fields unless the developer remembers to re-add them. The workaround is correct given the dev workflow constraint. To prevent silent regression, we should either: 1. Add a top-of-file comment clearly marking which sections were manually patched, so the next regen doesn't silently drop them, or 2. Prioritize the `spring.profiles.active=dev` + `generate:api` cycle for future PRs that touch the OpenAPI surface. No architectural issue with the design itself.
Author
Owner

🔐 Nora Steiner "NullX" — Application Security Engineer

Verdict: Approved

No vulnerabilities found. Clean surface expansion.

What I checked

SQL injection
findRolledUpActivityFeed uses named parameters (:currentUserId, :limit) for all external input. The new (ag.payload->>'commentId')::uuid expression reads from a stored JSONB column — not user-controlled input, cannot be manipulated externally.

IDOR
The activity feed is a global feed showing participant activity on accessible documents. Exposing commentId is consistent with the existing exposure of documentId, actorId, and youParticipated. Comment content is not surfaced — only the ID for deep-linking. The document detail page enforces its own authorization before rendering comment threads, so a bare UUID in the feed URL doesn't bypass anything.

URL construction
buildCommentHref builds URLs from server-provided UUIDs (hex digits and hyphens only). No user-controlled strings reach the URL. No XSS surface.

No new auth boundary
DashboardService.getActivity is an authenticated endpoint. CommentService.findAnnotationIdsByIds is called from within the service layer, not exposed as a new API endpoint.

Suggestion (cosmetic hardening, not a blocker)

buildCommentHref uses string interpolation with UUID inputs. Since UUIDs are always URL-safe today this is fine, but URLSearchParams would make the function robust if input types ever widen:

export function buildCommentHref(documentId: string, commentId: string, annotationId: string | null): string {
    const params = new URLSearchParams({ commentId });
    if (annotationId) params.set('annotationId', annotationId);
    return `/documents/${documentId}?${params.toString()}`;
}

Not a current vulnerability — a suggestion for defensive coding.

## 🔐 Nora Steiner "NullX" — Application Security Engineer **Verdict: ✅ Approved** No vulnerabilities found. Clean surface expansion. ### What I checked **SQL injection ✅** `findRolledUpActivityFeed` uses named parameters (`:currentUserId`, `:limit`) for all external input. The new `(ag.payload->>'commentId')::uuid` expression reads from a stored JSONB column — not user-controlled input, cannot be manipulated externally. **IDOR ✅** The activity feed is a global feed showing participant activity on accessible documents. Exposing `commentId` is consistent with the existing exposure of `documentId`, `actorId`, and `youParticipated`. Comment *content* is not surfaced — only the ID for deep-linking. The document detail page enforces its own authorization before rendering comment threads, so a bare UUID in the feed URL doesn't bypass anything. **URL construction ✅** `buildCommentHref` builds URLs from server-provided UUIDs (hex digits and hyphens only). No user-controlled strings reach the URL. No XSS surface. **No new auth boundary ✅** `DashboardService.getActivity` is an authenticated endpoint. `CommentService.findAnnotationIdsByIds` is called from within the service layer, not exposed as a new API endpoint. ### Suggestion (cosmetic hardening, not a blocker) `buildCommentHref` uses string interpolation with UUID inputs. Since UUIDs are always URL-safe today this is fine, but `URLSearchParams` would make the function robust if input types ever widen: ```typescript export function buildCommentHref(documentId: string, commentId: string, annotationId: string | null): string { const params = new URLSearchParams({ commentId }); if (annotationId) params.set('annotationId', annotationId); return `/documents/${documentId}?${params.toString()}`; } ``` Not a current vulnerability — a suggestion for defensive coding.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Good test pyramid coverage overall. Two small gaps.

What I verified

Backend unit tests

  • CommentServiceTest — 4 new tests: known IDs return map, empty input short-circuits (with never() verify), unknown IDs omitted, null annotationId omitted. Excellent edge case coverage.
  • DashboardServiceTest — 3 new tests: populates commentId, populates annotationId via service, leaves both null for non-comment kinds with never() verify. Correctly uses @Mock CommentService + @InjectMocks.

Backend integration tests

  • AuditLogQueryRepositoryRolledUpTest — 3 new integration tests against real PostgreSQL (Testcontainers). Verifies the SQL projection for COMMENT_ADDED, MENTION_CREATED, and null for TEXT_SAVED. Correct layer for testing native queries.

Frontend unit tests

  • commentDeepLink.spec.ts — 2 tests covering both branches (with/without annotationId).
  • ChronikRow.svelte.spec.ts — 3 new component tests.
  • ChronikFuerDichBox.svelte.spec.ts — 1 new test for annotationId in href.

Gaps (suggestions, not hard blockers)

1. Missing: ChronikRow test for commentId present + annotationId absent
The existing fallback test covers the case where commentId is absent. But the case where commentId is present and annotationId is absent (comment on a non-annotation block) produces /documents/doc-1?commentId=comment-7 — this branch is exercised in commentDeepLink.spec.ts but not at the ChronikRow component level. Low risk since the helper is separately tested, but a component-level assertion would make the suite more self-documenting.

2. Missing: NotificationBell.svelte.spec.ts update for annotationId path
handleMarkRead was refactored to call buildCommentHref, but I don't see a new test in NotificationBell.svelte.spec.ts exercising the annotationId-present branch. Since buildCommentHref is tested in isolation, the risk is minimal — worth adding for completeness.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Good test pyramid coverage overall. Two small gaps. ### What I verified **Backend unit tests ✅** - `CommentServiceTest` — 4 new tests: known IDs return map, empty input short-circuits (with `never()` verify), unknown IDs omitted, null `annotationId` omitted. Excellent edge case coverage. - `DashboardServiceTest` — 3 new tests: populates `commentId`, populates `annotationId` via service, leaves both null for non-comment kinds with `never()` verify. Correctly uses `@Mock CommentService` + `@InjectMocks`. ✅ **Backend integration tests ✅** - `AuditLogQueryRepositoryRolledUpTest` — 3 new integration tests against real PostgreSQL (Testcontainers). Verifies the SQL projection for `COMMENT_ADDED`, `MENTION_CREATED`, and null for `TEXT_SAVED`. Correct layer for testing native queries. ✅ **Frontend unit tests ✅** - `commentDeepLink.spec.ts` — 2 tests covering both branches (with/without annotationId). ✅ - `ChronikRow.svelte.spec.ts` — 3 new component tests. ✅ - `ChronikFuerDichBox.svelte.spec.ts` — 1 new test for `annotationId` in href. ✅ ### Gaps (suggestions, not hard blockers) **1. Missing: `ChronikRow` test for commentId present + annotationId absent** The existing fallback test covers the case where `commentId` is absent. But the case where `commentId` is present and `annotationId` is absent (comment on a non-annotation block) produces `/documents/doc-1?commentId=comment-7` — this branch is exercised in `commentDeepLink.spec.ts` but not at the `ChronikRow` component level. Low risk since the helper is separately tested, but a component-level assertion would make the suite more self-documenting. **2. Missing: `NotificationBell.svelte.spec.ts` update for `annotationId` path** `handleMarkRead` was refactored to call `buildCommentHref`, but I don't see a new test in `NotificationBell.svelte.spec.ts` exercising the `annotationId`-present branch. Since `buildCommentHref` is tested in isolation, the risk is minimal — worth adding for completeness.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Pure application code change — no infrastructure impact.

What I checked

  • No Docker Compose changes
  • No CI workflow changes
  • No new environment variables
  • No new Flyway migrations — commentId is extracted from the existing payload JSONB column, no schema change needed
  • No changes to package.json or pom.xml that affect build tooling
  • No new services, ports, or volumes

The approach of extracting commentId from the existing payload JSONB column rather than adding a new nullable database column is the right call — it avoids a migration entirely and relies on data already present in the schema.

Nothing to flag from an infrastructure perspective.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Pure application code change — no infrastructure impact. ### What I checked - No Docker Compose changes - No CI workflow changes - No new environment variables - No new Flyway migrations — `commentId` is extracted from the existing `payload` JSONB column, no schema change needed - No changes to `package.json` or `pom.xml` that affect build tooling - No new services, ports, or volumes The approach of extracting `commentId` from the existing `payload` JSONB column rather than adding a new nullable database column is the right call — it avoids a migration entirely and relies on data already present in the schema. Nothing to flag from an infrastructure perspective.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist

Verdict: Approved

Navigation behavior improvement with no visual regressions.

What I checked

Navigation deep-link
ChronikRow now links directly to the specific comment and annotation when commentId is present. This is better UX for the target audience — clicking a "For You" activity item lands the user at exactly the right place in the document, not the top of the page. For seniors (60+), fewer navigation steps after a click matter significantly.

Accessibility

  • The <a> element in ChronikRow.svelte retains its existing focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none focus indicator. No regression.
  • Link text remains the document title — descriptive, never a raw URL. Screen readers will announce meaningful content.
  • No new icon-only buttons or unlabeled interactive elements introduced.

Brand compliance
No styling changes in this PR. All modified components (ChronikRow, ChronikFuerDichBox, NotificationBell) have identical visual markup — only the href computation changed.

No visual regressions
This is a purely behavioral change (link destination). Layout, color, typography, and spacing are unchanged across all three components.

One observation

The "For You" feed items (variant === 'for-you') correctly receive the deep-link just like regular feed items. When the feed row says "Anna mentioned you", landing directly at the mention is exactly the user's expectation — no extra navigation needed.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist **Verdict: ✅ Approved** Navigation behavior improvement with no visual regressions. ### What I checked **Navigation deep-link ✅** `ChronikRow` now links directly to the specific comment and annotation when `commentId` is present. This is better UX for the target audience — clicking a "For You" activity item lands the user at exactly the right place in the document, not the top of the page. For seniors (60+), fewer navigation steps after a click matter significantly. **Accessibility ✅** - The `<a>` element in `ChronikRow.svelte` retains its existing `focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none` focus indicator. No regression. ✅ - Link text remains the document title — descriptive, never a raw URL. Screen readers will announce meaningful content. ✅ - No new icon-only buttons or unlabeled interactive elements introduced. **Brand compliance ✅** No styling changes in this PR. All modified components (`ChronikRow`, `ChronikFuerDichBox`, `NotificationBell`) have identical visual markup — only the `href` computation changed. **No visual regressions ✅** This is a purely behavioral change (link destination). Layout, color, typography, and spacing are unchanged across all three components. ### One observation The "For You" feed items (`variant === 'for-you'`) correctly receive the deep-link just like regular feed items. When the feed row says "Anna mentioned you", landing directly at the mention is exactly the user's expectation — no extra navigation needed.
marcel added 3 commits 2026-04-21 18:41:11 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chore(api): mark manually-patched fields for next regen cycle
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 2m47s
CI / Unit & Component Tests (push) Failing after 2m54s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 2m59s
7d9c7f1357
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed — 3 commits pushed

Sara + Felix gap #1 — ChronikRow commentId-only URL coverage

Commit a15e4e13test(chronik-row): add coverage for commentId-only URL when annotationId absent

Added the missing component-level test for COMMENT_ADDED with commentId present but annotationId absent, asserting the link resolves to /documents/doc-1?commentId=comment-7 (no annotationId segment). The helper's branch was already covered in commentDeepLink.spec.ts; this test closes the gap at the component level.

Sara + Felix gap #2 — NotificationBell handleMarkRead annotationId path

Commit a76af739test(notification-bell): cover handleMarkRead annotationId and commentId-only paths

Created NotificationBell.svelte.spec.ts (first spec for this component). Mocks $app/navigation and $lib/stores/notifications.svelte, opens the bell dropdown, clicks the notification button, and asserts goto is called with the correct URL. Two tests:

  • annotationId present → URL includes &annotationId=annot-1
  • annotationId absent → URL contains only ?commentId=ref-1

Markus concern — api.ts hand-edit documented

Commit 7d9c7f13chore(api): mark manually-patched fields for next regen cycle

Added an inline // MANUALLY ADDED comment above the commentId/annotationId fields with a note to re-add them after the next npm run generate:api run. This makes the divergence risk visible to the next developer who runs the generator.


All three concerns resolved. Frontend: 835 tests passing, 72 test files.

## Review concerns addressed — 3 commits pushed ### ✅ Sara + Felix gap #1 — ChronikRow commentId-only URL coverage **Commit `a15e4e13`** — `test(chronik-row): add coverage for commentId-only URL when annotationId absent` Added the missing component-level test for COMMENT_ADDED with `commentId` present but `annotationId` absent, asserting the link resolves to `/documents/doc-1?commentId=comment-7` (no `annotationId` segment). The helper's branch was already covered in `commentDeepLink.spec.ts`; this test closes the gap at the component level. ### ✅ Sara + Felix gap #2 — NotificationBell handleMarkRead annotationId path **Commit `a76af739`** — `test(notification-bell): cover handleMarkRead annotationId and commentId-only paths` Created `NotificationBell.svelte.spec.ts` (first spec for this component). Mocks `$app/navigation` and `$lib/stores/notifications.svelte`, opens the bell dropdown, clicks the notification button, and asserts `goto` is called with the correct URL. Two tests: - `annotationId` present → URL includes `&annotationId=annot-1` ✅ - `annotationId` absent → URL contains only `?commentId=ref-1` ✅ ### ✅ Markus concern — api.ts hand-edit documented **Commit `7d9c7f13`** — `chore(api): mark manually-patched fields for next regen cycle` Added an inline `// MANUALLY ADDED` comment above the `commentId`/`annotationId` fields with a note to re-add them after the next `npm run generate:api` run. This makes the divergence risk visible to the next developer who runs the generator. --- All three concerns resolved. Frontend: 835 tests passing, 72 test files.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Solid, well-disciplined feature. The previous round's two test gaps are fully closed.

What's working well

  • buildCommentHref is correctly factored. Pure function, no side effects, single responsibility. Imported in exactly three places (ChronikRow, ChronikFuerDichBox, NotificationBell) — DRY without over-abstraction.
  • rowHref as $derived is idiomatic Svelte 5. Single expression, intent-revealing name. Correct handling of null annotation: item.annotationId ?? null.
  • findAnnotationIdsByIds in CommentService is clean. Guard clause at the top (if empty, return Map.of()), single loop, only adds entries when annotationId != null. Under 15 lines.
  • DashboardService enrichment logic is readable. Collects → batch-lookup → per-row resolution. The annotationByComment.get(commentId) correctly returns null for missing keys.
  • The api.ts MANUALLY ADDED comment explains the why, not the what — exactly right for a hand-edited generated file.

Suggestions (non-blocking)

  • ChronikRow.svelte.spec.ts uses document.querySelector('a[href="..."]') throughout. Querying by accessible role + name would be more resilient if the href format ever changes, but with UUID-less fixture values the attribute selector is readable and deterministic here. No action needed.
  • DashboardServiceTest includes a section-separator comment (// ─── getActivity ...). Fine in test code — it aids navigation without being a "what" comment.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Solid, well-disciplined feature. The previous round's two test gaps are fully closed. ### What's working well - **`buildCommentHref` is correctly factored.** Pure function, no side effects, single responsibility. Imported in exactly three places (ChronikRow, ChronikFuerDichBox, NotificationBell) — DRY without over-abstraction. - **`rowHref` as `$derived` is idiomatic Svelte 5.** Single expression, intent-revealing name. Correct handling of `null` annotation: `item.annotationId ?? null`. - **`findAnnotationIdsByIds` in `CommentService` is clean.** Guard clause at the top (`if empty, return Map.of()`), single loop, only adds entries when `annotationId != null`. Under 15 lines. - **DashboardService enrichment logic is readable.** Collects → batch-lookup → per-row resolution. The `annotationByComment.get(commentId)` correctly returns `null` for missing keys. - **The `api.ts` MANUALLY ADDED comment explains the *why***, not the what — exactly right for a hand-edited generated file. ### Suggestions (non-blocking) - `ChronikRow.svelte.spec.ts` uses `document.querySelector('a[href="..."]')` throughout. Querying by accessible role + name would be more resilient if the href format ever changes, but with UUID-less fixture values the attribute selector is readable and deterministic here. No action needed. - `DashboardServiceTest` includes a section-separator comment (`// ─── getActivity ...`). Fine in test code — it aids navigation without being a "what" comment.
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Verdict: Approved

The previous concern about api.ts maintenance risk is addressed. Layer boundaries are respected throughout.

What I checked

Domain boundary compliance —
DashboardService calls CommentService.findAnnotationIdsByIds() rather than reaching into CommentRepository directly. This is exactly the pattern the architecture requires: cross-domain data access goes through the owning service's API. No boundary violations.

Repository query layer —
(ag.payload->>'commentId')::uuid AS commentId is placed in the final SELECT of the rolled-up CTE, alongside all other projection columns. The column sits at the right layer — the repository interface owns what the query exposes; the service owns enrichment.

ActivityFeedItemDTO design —
@Nullable UUID commentId and @Nullable UUID annotationId are correct: these fields are absent for non-comment events and the @Schema(requiredMode = NOT_REQUIRED) annotation drives the OpenAPI spec correctly.

api.ts maintenance risk — addressed
The MANUALLY ADDED comment with explicit regeneration instructions is the right pragmatic solution while the backend isn't running in CI. When the backend is deployed, npm run generate:api + re-adding the fields closes the gap.

Notes

The batch approach in DashboardService (commentIds.stream().filter(nonNull).distinct().toList() → single findAnnotationIdsByIds call) is a sound N+1 prevention pattern, consistent with the existing documentService.getDocumentsByIds batch call directly above it.

## 🏗️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** The previous concern about `api.ts` maintenance risk is addressed. Layer boundaries are respected throughout. ### What I checked **Domain boundary compliance — ✅** `DashboardService` calls `CommentService.findAnnotationIdsByIds()` rather than reaching into `CommentRepository` directly. This is exactly the pattern the architecture requires: cross-domain data access goes through the owning service's API. No boundary violations. **Repository query layer — ✅** `(ag.payload->>'commentId')::uuid AS commentId` is placed in the final `SELECT` of the rolled-up CTE, alongside all other projection columns. The column sits at the right layer — the repository interface owns what the query exposes; the service owns enrichment. **`ActivityFeedItemDTO` design — ✅** `@Nullable UUID commentId` and `@Nullable UUID annotationId` are correct: these fields are absent for non-comment events and the `@Schema(requiredMode = NOT_REQUIRED)` annotation drives the OpenAPI spec correctly. **`api.ts` maintenance risk — ✅ addressed** The `MANUALLY ADDED` comment with explicit regeneration instructions is the right pragmatic solution while the backend isn't running in CI. When the backend is deployed, `npm run generate:api` + re-adding the fields closes the gap. ### Notes The batch approach in DashboardService (`commentIds.stream().filter(nonNull).distinct().toList()` → single `findAnnotationIdsByIds` call) is a sound N+1 prevention pattern, consistent with the existing `documentService.getDocumentsByIds` batch call directly above it.
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: Approved

Both gaps I flagged in the first review are fully addressed. Test coverage across the full stack is now comprehensive.

Previous gaps — resolved

Gap 1: ChronikRow commentId-only URL
ChronikRow.svelte.spec.ts now includes the commentId is set but annotationId is absent case. This was the missing branch in buildCommentHref at the component level.

Gap 2: NotificationBell tests
NotificationBell.svelte.spec.ts is a new 82-line file with both branches covered: annotationId-present → URL includes &annotationId=annot-1, annotationId-null → commentId-only URL. The mock setup (vi.hoisted, vi.mock for $app/navigation and notifications.svelte) is correct.

Full coverage audit

Layer Tests Assessment
SQL projection (AuditLogQueryRepositoryRolledUpTest) 3 tests: COMMENT_ADDED, MENTION_CREATED, non-comment null All branches
Service enrichment (DashboardServiceTest) 3 tests: commentId populated, annotationId populated, both null for non-comment kinds Includes verify(commentService, never()) for the short-circuit
CommentService.findAnnotationIdsByIds 4 tests: known IDs, empty input, unknown IDs omitted, null annotationId omitted All edge cases
buildCommentHref utility 2 tests: with/without annotationId
ChronikRow component 4 new tests (full URL, MENTION_CREATED, bare fallback, commentId-only)
ChronikFuerDichBox component 1 new test: annotationId included in href
NotificationBell component 2 new tests: both annotationId branches

This is good pyramid coverage: unit tests prove the logic; component tests prove the rendering; no E2E needed for URL construction.

## 🧪 Sara Holt — QA Engineer **Verdict: ✅ Approved** Both gaps I flagged in the first review are fully addressed. Test coverage across the full stack is now comprehensive. ### Previous gaps — resolved **Gap 1: ChronikRow commentId-only URL** ✅ `ChronikRow.svelte.spec.ts` now includes the `commentId is set but annotationId is absent` case. This was the missing branch in `buildCommentHref` at the component level. **Gap 2: NotificationBell tests** ✅ `NotificationBell.svelte.spec.ts` is a new 82-line file with both branches covered: annotationId-present → URL includes `&annotationId=annot-1`, annotationId-null → commentId-only URL. The mock setup (`vi.hoisted`, `vi.mock` for `$app/navigation` and `notifications.svelte`) is correct. ### Full coverage audit | Layer | Tests | Assessment | |---|---|---| | SQL projection (`AuditLogQueryRepositoryRolledUpTest`) | 3 tests: COMMENT_ADDED, MENTION_CREATED, non-comment null | ✅ All branches | | Service enrichment (`DashboardServiceTest`) | 3 tests: commentId populated, annotationId populated, both null for non-comment kinds | ✅ Includes `verify(commentService, never())` for the short-circuit | | `CommentService.findAnnotationIdsByIds` | 4 tests: known IDs, empty input, unknown IDs omitted, null annotationId omitted | ✅ All edge cases | | `buildCommentHref` utility | 2 tests: with/without annotationId | ✅ | | `ChronikRow` component | 4 new tests (full URL, MENTION_CREATED, bare fallback, commentId-only) | ✅ | | `ChronikFuerDichBox` component | 1 new test: annotationId included in href | ✅ | | `NotificationBell` component | 2 new tests: both annotationId branches | ✅ | This is good pyramid coverage: unit tests prove the logic; component tests prove the rendering; no E2E needed for URL construction.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No security vulnerabilities introduced. Audit of all attack surfaces is clean.

What I checked

JSONB extraction in SQL — safe

(ag.payload->>'commentId')::uuid AS commentId

The ::uuid cast means PostgreSQL will reject any non-UUID string in the payload with a cast error (or return null for null values). This field originates from backend-serialized audit log entries — it is server-controlled, not user-controlled. No injection surface.

findAnnotationIdsByIds safe
Uses commentRepository.findAllById(commentIds) — this is Spring Data's built-in parameterized query. The input is a Collection<UUID>, already typed. No JPQL string concatenation.

Deep-link URL construction — acceptable
buildCommentHref builds URLs from documentId, commentId, and annotationId. In the flow that reaches this function, all three values originate from the backend response (UUIDs and server-assigned IDs), not from user-controlled input. The Svelte router (goto()) handles the resulting URL safely.

I noted in the first review that URLSearchParams would be marginally more encoding-safe as a cosmetic hardening. This remains a suggestion, not a blocker — the current values are UUID-format strings that contain no characters requiring encoding.

No new endpoints, no authorization changes — the activity feed endpoint's existing permission model is unchanged.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No security vulnerabilities introduced. Audit of all attack surfaces is clean. ### What I checked **JSONB extraction in SQL — ✅ safe** ```sql (ag.payload->>'commentId')::uuid AS commentId ``` The `::uuid` cast means PostgreSQL will reject any non-UUID string in the payload with a cast error (or return null for null values). This field originates from backend-serialized audit log entries — it is server-controlled, not user-controlled. No injection surface. **`findAnnotationIdsByIds` — ✅ safe** Uses `commentRepository.findAllById(commentIds)` — this is Spring Data's built-in parameterized query. The input is a `Collection<UUID>`, already typed. No JPQL string concatenation. **Deep-link URL construction — ✅ acceptable** `buildCommentHref` builds URLs from documentId, commentId, and annotationId. In the flow that reaches this function, all three values originate from the backend response (UUIDs and server-assigned IDs), not from user-controlled input. The Svelte router (`goto()`) handles the resulting URL safely. I noted in the first review that `URLSearchParams` would be marginally more encoding-safe as a cosmetic hardening. This remains a suggestion, not a blocker — the current values are UUID-format strings that contain no characters requiring encoding. **No new endpoints, no authorization changes** — the activity feed endpoint's existing permission model is unchanged.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

This PR makes no visual or structural UI changes — it wires deep-link navigation behind existing elements. All accessibility attributes are preserved unchanged.

What I checked

ARIA roles unchanged —

  • Bell button retains aria-haspopup="true" and aria-expanded={open}
  • Notification dropdown retains role="dialog" and role="list"
  • ChronikRow's <a> element retains focus-visible:ring-2 focus-visible:ring-focus-ring

User impact — positive
Before this PR, clicking a mention in the Chronik navigated to the bare document page; users had to scroll to find the referenced comment. After this PR, the deep-link scrolls directly to the comment and highlights it. This is a meaningful improvement for the senior audience — reduced cognitive load.

No new interactive elements, no contrast, spacing, or touch target changes.

The only UI-adjacent change is href value updates in three components, which correctly preserve the <a> element type (proper focusable landmark for keyboard navigation).

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This PR makes no visual or structural UI changes — it wires deep-link navigation behind existing elements. All accessibility attributes are preserved unchanged. ### What I checked **ARIA roles unchanged — ✅** - Bell button retains `aria-haspopup="true"` and `aria-expanded={open}` - Notification dropdown retains `role="dialog"` and `role="list"` - ChronikRow's `<a>` element retains `focus-visible:ring-2 focus-visible:ring-focus-ring` **User impact — positive** Before this PR, clicking a mention in the Chronik navigated to the bare document page; users had to scroll to find the referenced comment. After this PR, the deep-link scrolls directly to the comment and highlights it. This is a meaningful improvement for the senior audience — reduced cognitive load. **No new interactive elements, no contrast, spacing, or touch target changes.** The only UI-adjacent change is `href` value updates in three components, which correctly preserve the `<a>` element type (proper focusable landmark for keyboard navigation).
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. Quick audit of the relevant surfaces:

What I checked

  • docker-compose.yml — no changes
  • CI pipeline — no changes
  • pom.xml — no new dependencies added
  • package.json / package-lock.json — no new npm packages
  • No new environment variables or secrets needed for this feature

The batch enrichment path in DashboardService (stream → filter nonNull → distinct → batch CommentService call) adds at most one additional DB query per activity feed page load. This is negligible from an infrastructure perspective and consistent with the existing documentService.getDocumentsByIds pattern already in that method.

LGTM from a platform perspective.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. Quick audit of the relevant surfaces: ### What I checked - **`docker-compose.yml`** — no changes - **CI pipeline** — no changes - **`pom.xml`** — no new dependencies added - **`package.json` / `package-lock.json`** — no new npm packages - **No new environment variables or secrets** needed for this feature The batch enrichment path in `DashboardService` (stream → filter nonNull → distinct → batch CommentService call) adds at most one additional DB query per activity feed page load. This is negligible from an infrastructure perspective and consistent with the existing `documentService.getDocumentsByIds` pattern already in that method. LGTM from a platform perspective.
marcel merged commit 7d9c7f1357 into main 2026-04-21 19:06:19 +02:00
marcel deleted branch feat/issue-300-chronik-mention-deep-link 2026-04-21 19:06:20 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#301