From b9f5ec22aa92c5f8f8015916186936ec5e1226bd Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:00:36 +0200 Subject: [PATCH 01/11] feat(audit): expose commentId on rolled-up activity feed projection 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) --- .../familienarchiv/audit/ActivityFeedRow.java | 2 + .../audit/AuditLogQueryRepository.java | 3 +- .../AuditLogQueryRepositoryRolledUpTest.java | 39 +++++++++++++++++++ .../dashboard/DashboardServiceTest.java | 1 + 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java index d6b1f899..75964db4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java @@ -15,4 +15,6 @@ public interface ActivityFeedRow { boolean isYouParticipated(); int getCount(); Instant getHappenedAtUntil(); + /** Present only for COMMENT_ADDED and MENTION_CREATED — null otherwise. */ + UUID getCommentId(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java index 49c998e2..9c0957b8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -99,7 +99,8 @@ public interface AuditLogQueryRepository extends JpaRepository { AND n.reference_id = (ag.payload->>'commentId')::uuid ) AS youParticipated, ag.count AS count, - ag.happened_at_until AS happenedAtUntil + ag.happened_at_until AS happenedAtUntil, + (ag.payload->>'commentId')::uuid AS commentId FROM aggregated ag LEFT JOIN users u ON u.id = ag.actor_id ORDER BY ag.happened_at DESC diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java index 626903b4..02e66943 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java @@ -271,6 +271,45 @@ class AuditLogQueryRepositoryRolledUpTest { ); } + @Test + void rolledUpFeed_exposes_commentId_for_COMMENT_ADDED_events() { + insertUserAndDocs(); + UUID commentId = UUID.randomUUID(); + insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", + Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString())); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getCommentId()).isEqualTo(commentId); + } + + @Test + void rolledUpFeed_exposes_commentId_for_MENTION_CREATED_events() { + insertUserAndDocs(); + UUID commentId = UUID.randomUUID(); + insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED", + Instant.parse("2026-04-20T10:00:00Z"), + Map.of("commentId", commentId.toString(), "mentionedUserId", USER_ID.toString())); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getCommentId()).isEqualTo(commentId); + } + + @Test + void rolledUpFeed_commentId_is_null_for_non_comment_kinds() { + insertUserAndDocs(); + insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", + Instant.parse("2026-04-20T10:00:00Z"), Map.of("blockId", "ccc", "pageNumber", "1")); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getCommentId()).isNull(); + } + @Test void youMentioned_is_false_when_mention_created_payload_targets_different_user() { insertUserAndDocs(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java index 30a163d1..700b6fca 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -107,6 +107,7 @@ class DashboardServiceTest { public boolean isYouParticipated() { return false; } public int getCount() { return 1; } public Instant getHappenedAtUntil() { return null; } + public UUID getCommentId() { return null; } }; } } -- 2.49.1 From 40260be07aa823ee75014dc3ac6d19f786680a34 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:04:38 +0200 Subject: [PATCH 02/11] feat(comment): add findAnnotationIdsByIds batch lookup 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) --- .../service/CommentService.java | 11 ++++ .../service/CommentServiceTest.java | 58 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java index 554fa492..da34588f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -13,6 +13,8 @@ import org.raddatz.familienarchiv.repository.CommentRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collection; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -29,6 +31,15 @@ public class CommentService { private final AuditService auditService; private final TranscriptionService transcriptionService; + public Map findAnnotationIdsByIds(Collection commentIds) { + if (commentIds == null || commentIds.isEmpty()) return Map.of(); + Map result = new HashMap<>(); + for (DocumentComment c : commentRepository.findAllById(commentIds)) { + if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId()); + } + return result; + } + public List getCommentsForBlock(UUID blockId) { List roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId); return withRepliesAndMentions(roots); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java index 19eb0b8d..a7854424 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -641,6 +641,64 @@ class CommentServiceTest { verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any()); } + // ─── findAnnotationIdsByIds ─────────────────────────────────────────────── + + @Test + void findAnnotationIdsByIds_returnsMap_forKnownIds() { + UUID commentA = UUID.randomUUID(); + UUID annotationA = UUID.randomUUID(); + UUID commentB = UUID.randomUUID(); + UUID annotationB = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(commentA, commentB))) + .thenReturn(List.of( + DocumentComment.builder().id(commentA).annotationId(annotationA).build(), + DocumentComment.builder().id(commentB).annotationId(annotationB).build() + )); + + assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB))) + .containsOnly( + java.util.Map.entry(commentA, annotationA), + java.util.Map.entry(commentB, annotationB) + ); + } + + @Test + void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() { + assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty(); + verify(commentRepository, never()).findAllById(anyList()); + } + + @Test + void findAnnotationIdsByIds_omitsUnknownIds() { + UUID known = UUID.randomUUID(); + UUID knownAnnotation = UUID.randomUUID(); + UUID missing = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(known, missing))) + .thenReturn(List.of( + DocumentComment.builder().id(known).annotationId(knownAnnotation).build() + )); + + assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing))) + .containsOnly(java.util.Map.entry(known, knownAnnotation)) + .doesNotContainKey(missing); + } + + @Test + void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() { + UUID legacy = UUID.randomUUID(); + UUID block = UUID.randomUUID(); + UUID annotation = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(legacy, block))) + .thenReturn(List.of( + DocumentComment.builder().id(legacy).annotationId(null).build(), + DocumentComment.builder().id(block).annotationId(annotation).build() + )); + + assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block))) + .containsOnly(java.util.Map.entry(block, annotation)) + .doesNotContainKey(legacy); + } + private void stubBlock(UUID docId, UUID blockId) { when(transcriptionService.getBlock(docId, blockId)) .thenReturn(TranscriptionBlock.builder() -- 2.49.1 From f50a74661926bbd6580098e4dc8c0b3a2676df05 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:11:03 +0200 Subject: [PATCH 03/11] feat(dashboard): enrich activity feed DTO with commentId + annotationId 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) --- .../dashboard/ActivityFeedItemDTO.java | 14 +++- .../dashboard/DashboardService.java | 17 ++++- .../dashboard/DashboardServiceTest.java | 70 ++++++++++++++++++- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java index dbf6a0cd..b7767d15 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java @@ -17,5 +17,17 @@ public record ActivityFeedItemDTO( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count, - @Nullable OffsetDateTime happenedAtUntil + @Nullable OffsetDateTime happenedAtUntil, + @Nullable + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + description = "Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds." + ) + UUID commentId, + @Nullable + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds." + ) + UUID annotationId ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java index 9e2eea75..47f5fa13 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.service.CommentService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.TranscriptionService; import org.raddatz.familienarchiv.service.UserService; @@ -32,6 +33,7 @@ public class DashboardService { private final DocumentService documentService; private final TranscriptionService transcriptionService; private final UserService userService; + private final CommentService commentService; public DashboardResumeDTO getResume(UUID userId) { Optional docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId); @@ -125,6 +127,15 @@ public class DashboardService { log.warn("Activity: failed to bulk-load document titles", e); } + List commentIds = rows.stream() + .map(ActivityFeedRow::getCommentId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map annotationByComment = commentIds.isEmpty() + ? Map.of() + : commentService.findAnnotationIdsByIds(commentIds); + return rows.stream().map(row -> { ActivityActorDTO actor = row.getActorId() != null ? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName()) @@ -133,6 +144,8 @@ public class DashboardService { OffsetDateTime happenedAtUntil = row.getHappenedAtUntil() != null ? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC) : null; + UUID commentId = row.getCommentId(); + UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null; return new ActivityFeedItemDTO( org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()), actor, @@ -142,7 +155,9 @@ public class DashboardService { row.isYouMentioned(), row.isYouParticipated(), row.getCount(), - happenedAtUntil + happenedAtUntil, + commentId, + annotationId ); }).toList(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java index 700b6fca..d453f2e7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.service.CommentService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.TranscriptionService; import org.raddatz.familienarchiv.service.UserService; @@ -17,6 +18,7 @@ import org.raddatz.familienarchiv.service.UserService; import java.time.Instant; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -33,6 +35,7 @@ class DashboardServiceTest { @Mock DocumentService documentService; @Mock TranscriptionService transcriptionService; @Mock UserService userService; + @Mock CommentService commentService; @InjectMocks DashboardService dashboardService; @@ -94,7 +97,72 @@ class DashboardServiceTest { verify(documentService, never()).getDocumentById(docId); } + // ─── getActivity comment/annotation enrichment ──────────────────────────── + + @Test + void getActivity_populatesCommentId_forCommentEvents() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + + ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId); + when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row)); + when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of( + Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build() + )); + when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of()); + + List items = dashboardService.getActivity(userId, 5); + + assertThat(items).hasSize(1); + assertThat(items.get(0).commentId()).isEqualTo(commentId); + } + + @Test + void getActivity_populatesAnnotationId_viaCommentService() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID annotationId = UUID.randomUUID(); + + ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId); + when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row)); + when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of( + Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build() + )); + when(commentService.findAnnotationIdsByIds(List.of(commentId))) + .thenReturn(Map.of(commentId, annotationId)); + + List items = dashboardService.getActivity(userId, 5); + + assertThat(items).hasSize(1); + assertThat(items.get(0).annotationId()).isEqualTo(annotationId); + } + + @Test + void getActivity_leavesBothNull_forNonCommentKinds() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + + ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null); + when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row)); + when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of( + Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build() + )); + + List items = dashboardService.getActivity(userId, 5); + + assertThat(items).hasSize(1); + assertThat(items.get(0).commentId()).isNull(); + assertThat(items.get(0).annotationId()).isNull(); + verify(commentService, never()).findAnnotationIdsByIds(anyList()); + } + private ActivityFeedRow mockFeedRow(UUID docId, String kind) { + return mockFeedRow(docId, kind, null); + } + + private ActivityFeedRow mockFeedRow(UUID docId, String kind, UUID commentId) { return new ActivityFeedRow() { public String getKind() { return kind; } public UUID getActorId() { return null; } @@ -107,7 +175,7 @@ class DashboardServiceTest { public boolean isYouParticipated() { return false; } public int getCount() { return 1; } public Instant getHappenedAtUntil() { return null; } - public UUID getCommentId() { return null; } + public UUID getCommentId() { return commentId; } }; } } -- 2.49.1 From 76a3a2e04c4518eeca9207ef6da19e00b512c0c9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:13:56 +0200 Subject: [PATCH 04/11] chore(api): hand-edit generated types for commentId + annotationId 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) --- frontend/src/lib/generated/api.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index e2cb6a0e..9b126565 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2043,6 +2043,16 @@ export interface components { count: number; /** Format: date-time */ happenedAtUntil?: string; + /** + * Format: uuid + * @description Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds. + */ + commentId?: string; + /** + * Format: uuid + * @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds. + */ + annotationId?: string; }; InvitePrefillDTO: { firstName: string; -- 2.49.1 From 7f40c54b3ff0cbf087a7c7c29a58e4a674f1601d Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:15:48 +0200 Subject: [PATCH 05/11] feat(utils): add buildCommentHref helper for comment deep-links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/lib/utils/commentDeepLink.spec.ts | 14 ++++++++++++++ frontend/src/lib/utils/commentDeepLink.ts | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 frontend/src/lib/utils/commentDeepLink.spec.ts create mode 100644 frontend/src/lib/utils/commentDeepLink.ts diff --git a/frontend/src/lib/utils/commentDeepLink.spec.ts b/frontend/src/lib/utils/commentDeepLink.spec.ts new file mode 100644 index 00000000..15c7f103 --- /dev/null +++ b/frontend/src/lib/utils/commentDeepLink.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { buildCommentHref } from './commentDeepLink'; + +describe('buildCommentHref', () => { + it('includes both commentId and annotationId when annotationId is present', () => { + const href = buildCommentHref('doc-1', 'comment-2', 'annot-3'); + expect(href).toBe('/documents/doc-1?commentId=comment-2&annotationId=annot-3'); + }); + + it('omits annotationId when null', () => { + const href = buildCommentHref('doc-1', 'comment-2', null); + expect(href).toBe('/documents/doc-1?commentId=comment-2'); + }); +}); diff --git a/frontend/src/lib/utils/commentDeepLink.ts b/frontend/src/lib/utils/commentDeepLink.ts new file mode 100644 index 00000000..95b5ef1d --- /dev/null +++ b/frontend/src/lib/utils/commentDeepLink.ts @@ -0,0 +1,8 @@ +export function buildCommentHref( + documentId: string, + commentId: string, + annotationId: string | null +): string { + const base = `/documents/${documentId}?commentId=${commentId}`; + return annotationId ? `${base}&annotationId=${annotationId}` : base; +} -- 2.49.1 From 7c22e42b8fc577adb8e4890f250d4308f4c14dad Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:17:57 +0200 Subject: [PATCH 06/11] refactor(notification-bell): use buildCommentHref helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/lib/components/NotificationBell.svelte | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte index 03f5398a..0e390357 100644 --- a/frontend/src/lib/components/NotificationBell.svelte +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/actions/clickOutside'; import { notificationStore } from '$lib/stores/notifications.svelte'; +import { buildCommentHref } from '$lib/utils/commentDeepLink'; import NotificationDropdown from './NotificationDropdown.svelte'; let open = $state(false); @@ -31,9 +32,11 @@ function closeDropdown() { async function handleMarkRead(notification: Parameters[0]) { await stream.markRead(notification); - const url = notification.annotationId - ? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}` - : `/documents/${notification.documentId}?commentId=${notification.referenceId}`; + const url = buildCommentHref( + notification.documentId, + notification.referenceId, + notification.annotationId + ); closeDropdown(); goto(url); } -- 2.49.1 From 95c11b9b46c1b333801a173e4bd46a32a585bc95 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:21:16 +0200 Subject: [PATCH 07/11] feat(chronik-fuer-dich): include annotationId in mention deep-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../chronik/ChronikFuerDichBox.svelte | 3 ++- .../chronik/ChronikFuerDichBox.svelte.spec.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte index fd4b94f9..82f9a6e6 100644 --- a/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte +++ b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte @@ -2,6 +2,7 @@ import * as m from '$lib/paraglide/messages.js'; import { relativeTime } from '$lib/utils/time'; import type { NotificationItem } from '$lib/stores/notifications.svelte'; +import { buildCommentHref } from '$lib/utils/commentDeepLink'; interface Props { unread: NotificationItem[]; @@ -18,7 +19,7 @@ function verb(type: NotificationItem['type'], actor: string): string { } function href(n: NotificationItem): string { - return `/documents/${n.documentId}?commentId=${n.referenceId}`; + return buildCommentHref(n.documentId, n.referenceId, n.annotationId); } diff --git a/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts index edb53951..3bea1aae 100644 --- a/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts +++ b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts @@ -114,6 +114,25 @@ describe('ChronikFuerDichBox', () => { expect(onMarkRead.mock.calls[0][0]).toEqual(n); }); + it('mention row href includes both commentId and annotationId when annotationId is present', async () => { + render(ChronikFuerDichBox, { + unread: [ + notif({ + id: 'n-link', + documentId: 'doc-42', + referenceId: 'comment-7', + annotationId: 'annot-9' + }) + ], + onMarkRead: vi.fn(), + onMarkAllRead: vi.fn() + }); + const link = document.querySelector( + 'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' + ); + expect(link).not.toBeNull(); + }); + it('Dismiss button is a sibling of the document link, never nested inside ', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'x' })], -- 2.49.1 From e175e050f98376de090e0497af5e7f524f5c6015 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 17:38:50 +0200 Subject: [PATCH 08/11] feat(chronik-row): deep-link COMMENT_ADDED and MENTION_CREATED to comment Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/chronik/ChronikRow.svelte | 9 +++- .../chronik/ChronikRow.svelte.spec.ts | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/chronik/ChronikRow.svelte b/frontend/src/lib/components/chronik/ChronikRow.svelte index 1df3c75e..d15268a6 100644 --- a/frontend/src/lib/components/chronik/ChronikRow.svelte +++ b/frontend/src/lib/components/chronik/ChronikRow.svelte @@ -1,6 +1,7 @@ { expect(preview?.textContent).not.toContain('Brief vom 12. Juli 1920'); }); + // --- deep-link href for comment events --- + it('links to /documents/:id?commentId=…&annotationId=… for COMMENT_ADDED', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED', + commentId: 'comment-7', + annotationId: 'annot-9' + }; + render(ChronikRow, { item }); + const link = document.querySelector( + 'a[href="/documents/doc-1?commentId=comment-7&annotationId=annot-9"]' + ); + expect(link).not.toBeNull(); + }); + + it('links to /documents/:id?commentId=…&annotationId=… for MENTION_CREATED', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'MENTION_CREATED', + youMentioned: true, + commentId: 'comment-8', + annotationId: 'annot-11' + }; + render(ChronikRow, { item }); + const link = document.querySelector( + 'a[href="/documents/doc-1?commentId=comment-8&annotationId=annot-11"]' + ); + expect(link).not.toBeNull(); + }); + + it('falls back to bare document href when commentId is absent on a comment row', async () => { + // Back-compat for old/missing backend payloads. Still navigates sensibly. + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED' + }; + render(ChronikRow, { item }); + const link = document.querySelector('a[href="/documents/doc-1"]'); + expect(link).not.toBeNull(); + }); + // --- robustness: title rendering for edge cases --- it('still renders the row link when documentTitle is an empty string', async () => { // Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span -- 2.49.1 From a15e4e139be0631fa701ac4827849ac5a45b0ff8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 18:34:41 +0200 Subject: [PATCH 09/11] test(chronik-row): add coverage for commentId-only URL when annotationId absent Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/chronik/ChronikRow.svelte.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts index 76ac3810..c95e761b 100644 --- a/frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts +++ b/frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts @@ -174,6 +174,18 @@ describe('ChronikRow', () => { expect(link).not.toBeNull(); }); + it('links to commentId-only URL when commentId is set but annotationId is absent', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED', + commentId: 'comment-7' + // annotationId absent — comment on a non-annotation block + }; + render(ChronikRow, { item }); + const link = document.querySelector('a[href="/documents/doc-1?commentId=comment-7"]'); + expect(link).not.toBeNull(); + }); + // --- robustness: title rendering for edge cases --- it('still renders the row link when documentTitle is an empty string', async () => { // Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span -- 2.49.1 From a76af739e561dbb83ba6552c40cf1090780ae955 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 18:37:18 +0200 Subject: [PATCH 10/11] test(notification-bell): cover handleMarkRead annotationId and commentId-only paths Co-Authored-By: Claude Sonnet 4.6 --- .../NotificationBell.svelte.spec.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 frontend/src/lib/components/NotificationBell.svelte.spec.ts diff --git a/frontend/src/lib/components/NotificationBell.svelte.spec.ts b/frontend/src/lib/components/NotificationBell.svelte.spec.ts new file mode 100644 index 00000000..733f950d --- /dev/null +++ b/frontend/src/lib/components/NotificationBell.svelte.spec.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import type { NotificationItem } from '$lib/utils/notifications'; +import NotificationBell from './NotificationBell.svelte'; + +const gotoMock = vi.hoisted(() => vi.fn()); +vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() })); + +const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] })); + +vi.mock('$lib/stores/notifications.svelte', () => ({ + notificationStore: { + get notifications() { + return mockNotificationList.value; + }, + get unreadCount() { + return mockNotificationList.value.length; + }, + markRead: mockMarkRead, + fetchNotifications: vi.fn().mockResolvedValue(undefined), + init: vi.fn(), + destroy: vi.fn(), + markAllRead: vi.fn() + } +})); + +afterEach(() => { + cleanup(); + gotoMock.mockClear(); + mockMarkRead.mockClear(); + mockNotificationList.value = []; +}); + +const makeNotification = (overrides: Partial = {}): NotificationItem => ({ + id: 'n1', + type: 'REPLY', + documentId: 'doc-1', + referenceId: 'ref-1', + annotationId: null, + read: false, + createdAt: '2026-04-21T10:00:00Z', + actorName: 'Anna', + documentTitle: 'Test Doc', + ...overrides +}); + +async function openDropdownAndClickFirstNotification() { + const bellButton = document.querySelector('button[aria-haspopup="true"]')!; + bellButton.click(); + await vi.waitFor(() => { + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + const notifButton = document.querySelector('[role="list"] button')!; + notifButton.click(); +} + +describe('NotificationBell', () => { + it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => { + mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })]; + render(NotificationBell); + + await openDropdownAndClickFirstNotification(); + + await vi.waitFor(() => { + expect(gotoMock).toHaveBeenCalledWith( + '/documents/doc-1?commentId=ref-1&annotationId=annot-1' + ); + }); + }); + + it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => { + mockNotificationList.value = [makeNotification({ annotationId: null })]; + render(NotificationBell); + + await openDropdownAndClickFirstNotification(); + + await vi.waitFor(() => { + expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1'); + }); + }); +}); -- 2.49.1 From 7d9c7f1357d3c9d95e84e822567567bbb42db41e Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 18:38:16 +0200 Subject: [PATCH 11/11] chore(api): mark manually-patched fields for next regen cycle Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9b126565..32095fc9 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2043,6 +2043,7 @@ export interface components { count: number; /** Format: date-time */ happenedAtUntil?: string; + // MANUALLY ADDED — re-run `npm run generate:api` after the next backend deployment and re-add these fields if they are dropped /** * Format: uuid * @description Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds. -- 2.49.1