From 7c25d08506ff68da3e391191daf1256a55e5b331 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 17:49:40 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(comment):=20add=20findDataByIds()=20?= =?UTF-8?q?=E2=80=94=20batch-fetch=20annotationId=20+=20plain-text=20previ?= =?UTF-8?q?ew=20in=20one=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-purpose findAnnotationIdsByIds() (kept as delegation shim). Introduces CommentData record (annotationId + preview) and stripAndTruncate() using Jsoup.parse().text() for DOM-safe HTML stripping. Truncates to 120 chars. Co-Authored-By: Claude Sonnet 4.6 --- backend/pom.xml | 7 ++ .../document/comment/CommentService.java | 24 ++++- .../document/comment/CommentServiceTest.java | 96 +++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/backend/pom.xml b/backend/pom.xml index e4e2eb2b..daa14df3 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -190,6 +190,13 @@ owasp-java-html-sanitizer 20240325.1 + + + + org.jsoup + jsoup + 1.18.1 + diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java index 33af36ce..2fccb84a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java @@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.document.comment.DocumentComment; import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock; import org.raddatz.familienarchiv.document.comment.CommentRepository; import org.raddatz.familienarchiv.notification.NotificationService; +import org.jsoup.Jsoup; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,26 +24,43 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class CommentService { + private static final int PREVIEW_MAX_CHARS = 120; + + public record CommentData(UUID annotationId, String preview) {} + private final CommentRepository commentRepository; private final UserService userService; private final NotificationService notificationService; private final AuditService auditService; private final TranscriptionService transcriptionService; - public Map findAnnotationIdsByIds(Collection commentIds) { + public Map findDataByIds(Collection commentIds) { if (commentIds == null || commentIds.isEmpty()) return Map.of(); - Map result = new HashMap<>(); + Map result = new HashMap<>(); for (DocumentComment c : commentRepository.findAllById(commentIds)) { - if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId()); + result.put(c.getId(), new CommentData(c.getAnnotationId(), stripAndTruncate(c.getContent()))); } return result; } + public Map findAnnotationIdsByIds(Collection commentIds) { + return findDataByIds(commentIds).entrySet().stream() + .filter(e -> e.getValue().annotationId() != null) + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().annotationId())); + } + + private String stripAndTruncate(String html) { + if (html == null || html.isBlank()) return ""; + String text = Jsoup.parse(html).text().trim(); + return text.length() > PREVIEW_MAX_CHARS ? text.substring(0, PREVIEW_MAX_CHARS) : text; + } + public List getCommentsForBlock(UUID blockId) { List roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId); return withRepliesAndMentions(roots); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java index f832cb46..897b1e34 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java @@ -19,6 +19,7 @@ import org.raddatz.familienarchiv.notification.NotificationService; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -644,6 +645,101 @@ class CommentServiceTest { verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any()); } + // ─── findDataByIds ──────────────────────────────────────────────────────── + + @Test + void findDataByIds_returns_empty_map_when_input_is_empty() { + assertThat(commentService.findDataByIds(List.of())).isEmpty(); + verify(commentRepository, never()).findAllById(anyList()); + } + + @Test + void findDataByIds_strips_html_and_extracts_plain_text() { + UUID id = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(id))) + .thenReturn(List.of(DocumentComment.builder().id(id) + .content("

Hello world

").build())); + + Map result = commentService.findDataByIds(List.of(id)); + + assertThat(result.get(id).preview()).isEqualTo("Hello world"); + } + + @Test + void findDataByIds_truncates_at_exactly_120_chars() { + UUID id = UUID.randomUUID(); + String text121 = "a".repeat(121); + when(commentRepository.findAllById(List.of(id))) + .thenReturn(List.of(DocumentComment.builder().id(id).content(text121).build())); + + assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120); + } + + @Test + void findDataByIds_does_not_truncate_at_exactly_120_chars() { + UUID id = UUID.randomUUID(); + String text120 = "a".repeat(120); + when(commentRepository.findAllById(List.of(id))) + .thenReturn(List.of(DocumentComment.builder().id(id).content(text120).build())); + + assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120); + } + + @Test + void findDataByIds_returns_empty_string_for_blank_content() { + UUID id = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(id))) + .thenReturn(List.of(DocumentComment.builder().id(id).content(" ").build())); + + assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty(); + } + + @Test + void findDataByIds_returns_empty_string_for_null_content() { + UUID id = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(id))) + .thenReturn(List.of(DocumentComment.builder().id(id).content(null).build())); + + assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty(); + } + + @Test + void findDataByIds_omits_deleted_comments_from_result_map() { + UUID present = UUID.randomUUID(); + UUID deleted = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(present, deleted))) + .thenReturn(List.of(DocumentComment.builder().id(present).content("Hi").build())); + + Map result = commentService.findDataByIds(List.of(present, deleted)); + + assertThat(result).containsKey(present); + assertThat(result).doesNotContainKey(deleted); + } + + @Test + void findDataByIds_preserves_annotationId_alongside_preview() { + UUID id = UUID.randomUUID(); + UUID annotationId = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(id))) + .thenReturn(List.of(DocumentComment.builder().id(id) + .annotationId(annotationId).content("Text").build())); + + CommentService.CommentData data = commentService.findDataByIds(List.of(id)).get(id); + + assertThat(data.annotationId()).isEqualTo(annotationId); + assertThat(data.preview()).isEqualTo("Text"); + } + + @Test + void findDataByIds_sets_null_annotationId_when_comment_has_no_annotation() { + UUID id = UUID.randomUUID(); + when(commentRepository.findAllById(List.of(id))) + .thenReturn(List.of(DocumentComment.builder().id(id) + .annotationId(null).content("Text").build())); + + assertThat(commentService.findDataByIds(List.of(id)).get(id).annotationId()).isNull(); + } + // ─── findAnnotationIdsByIds ─────────────────────────────────────────────── @Test -- 2.49.1 From e877847b7ecec842a7c98e93e471570b8cc93b35 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 17:55:02 +0200 Subject: [PATCH 2/6] feat(dashboard): add commentPreview to ActivityFeedItemDTO; wire via findDataByIds() ActivityFeedItemDTO gains a nullable commentPreview field (plain-text, 120 chars max). DashboardService.getActivity() now calls findDataByIds() once instead of findAnnotationIdsByIds(), halving DB round-trips for the Chronik page load. Empty-string previews are normalised to null so the frontend can use ?? cleanly. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/ActivityFeedItemDTO.java | 8 ++- .../dashboard/DashboardService.java | 13 ++-- .../dashboard/DashboardServiceTest.java | 65 +++++++++++++++++-- 3 files changed, 77 insertions(+), 9 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 b7767d15..bcd24bdb 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java @@ -29,5 +29,11 @@ public record ActivityFeedItemDTO( requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds." ) - UUID annotationId + UUID annotationId, + @Nullable + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + description = "Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments." + ) + String commentPreview ) {} 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 3b1300fd..5b5e03a1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock; import org.raddatz.familienarchiv.document.comment.CommentService; +import org.raddatz.familienarchiv.document.comment.CommentService.CommentData; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.document.transcription.TranscriptionService; import org.raddatz.familienarchiv.user.UserService; @@ -133,9 +134,9 @@ public class DashboardService { .filter(Objects::nonNull) .distinct() .toList(); - Map annotationByComment = commentIds.isEmpty() + Map commentDataByComment = commentIds.isEmpty() ? Map.of() - : commentService.findAnnotationIdsByIds(commentIds); + : commentService.findDataByIds(commentIds); return rows.stream().map(row -> { ActivityActorDTO actor = row.getActorId() != null @@ -146,7 +147,10 @@ public class DashboardService { ? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC) : null; UUID commentId = row.getCommentId(); - UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null; + CommentData commentData = commentId != null ? commentDataByComment.get(commentId) : null; + UUID annotationId = commentData != null ? commentData.annotationId() : null; + String commentPreview = commentData != null && !commentData.preview().isEmpty() + ? commentData.preview() : null; return new ActivityFeedItemDTO( org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()), actor, @@ -158,7 +162,8 @@ public class DashboardService { row.getCount(), happenedAtUntil, commentId, - annotationId + annotationId, + commentPreview ); }).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 472bbaf3..ab790806 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock; import org.raddatz.familienarchiv.document.comment.CommentService; +import org.raddatz.familienarchiv.document.comment.CommentService.CommentData; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.document.transcription.TranscriptionService; import org.raddatz.familienarchiv.user.UserService; @@ -142,7 +143,8 @@ class DashboardServiceTest { 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()); + when(commentService.findDataByIds(List.of(commentId))) + .thenReturn(Map.of(commentId, new CommentData(null, "preview text"))); List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); @@ -162,8 +164,8 @@ class DashboardServiceTest { 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)); + when(commentService.findDataByIds(List.of(commentId))) + .thenReturn(Map.of(commentId, new CommentData(annotationId, "preview text"))); List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); @@ -187,7 +189,62 @@ class DashboardServiceTest { assertThat(items).hasSize(1); assertThat(items.get(0).commentId()).isNull(); assertThat(items.get(0).annotationId()).isNull(); - verify(commentService, never()).findAnnotationIdsByIds(anyList()); + verify(commentService, never()).findDataByIds(anyList()); + } + + // ─── getActivity commentPreview ─────────────────────────────────────────── + + @Test + void getActivity_populates_commentPreview_for_COMMENT_ADDED_rows() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + + ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId); + when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).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.findDataByIds(List.of(commentId))) + .thenReturn(Map.of(commentId, new CommentData(null, "Hello family!"))); + + List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); + + assertThat(items.get(0).commentPreview()).isEqualTo("Hello family!"); + } + + @Test + void getActivity_leaves_commentPreview_null_for_TEXT_SAVED_rows() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + + ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null); + when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).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, AuditKind.ROLLUP_ELIGIBLE); + + assertThat(items.get(0).commentPreview()).isNull(); + } + + @Test + void getActivity_leaves_commentPreview_null_when_comment_is_deleted() { + UUID userId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID deletedCommentId = UUID.randomUUID(); + + ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", deletedCommentId); + when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).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.findDataByIds(List.of(deletedCommentId))).thenReturn(Map.of()); + + List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); + + assertThat(items.get(0).commentPreview()).isNull(); } // ─── getPulse — always uses full ROLLUP_ELIGIBLE set ───────────────────── -- 2.49.1 From e3a3f209f971010bc3ebd36e1fd1929cc89c4dfa Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 18:53:26 +0200 Subject: [PATCH 3/6] feat(chronik): render commentPreview in ChronikRow; add aria-label for screen readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the „…" placeholder with {item.commentPreview ?? '„…"'}. Plain-text binding — no {@html} — as specified in the security note from issue #285. Adds aria-label to the wrapper for COMMENT_ADDED rows that carry a preview, giving screen reader users the full context in one announcement. Generated api.ts updated manually to include commentPreview?:string; will be regenerated by npm run generate:api once the backend is running. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/activity/ChronikRow.svelte | 14 ++---- .../lib/activity/ChronikRow.svelte.spec.ts | 43 +++++++++++++++++++ frontend/src/lib/generated/api.ts | 2 + 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/activity/ChronikRow.svelte b/frontend/src/lib/activity/ChronikRow.svelte index a324066e..2b2cb904 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte +++ b/frontend/src/lib/activity/ChronikRow.svelte @@ -108,6 +108,9 @@ const rowHref: string = $derived( @@ -159,20 +162,11 @@ const rowHref: string = $derived(

{#if variant === 'comment'} -

- „…“ + {item.commentPreview ?? '„…"'}

{/if} diff --git a/frontend/src/lib/activity/ChronikRow.svelte.spec.ts b/frontend/src/lib/activity/ChronikRow.svelte.spec.ts index c95e761b..827ce16c 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikRow.svelte.spec.ts @@ -186,6 +186,49 @@ describe('ChronikRow', () => { expect(link).not.toBeNull(); }); + // --- commentPreview content --- + it('renders commentPreview text when variant is comment and commentPreview is present', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED', + commentPreview: 'Hello family, great letter!' + }; + render(ChronikRow, { item }); + const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toContain('Hello family, great letter!'); + }); + + it('renders placeholder ellipsis when variant is comment and commentPreview is null', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED', + commentPreview: undefined + }; + render(ChronikRow, { item }); + const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); + expect(preview).not.toBeNull(); + expect(preview?.textContent?.trim()).toBe('„…"'); + }); + + it('does not render preview paragraph for non-comment variants', async () => { + const item: ActivityFeedItemDTO = { ...baseItem, kind: 'TEXT_SAVED' }; + render(ChronikRow, { item }); + expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull(); + }); + + it('link has aria-label containing preview text for comment variant with preview', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED', + commentPreview: 'A wonderful letter from grandma' + }; + render(ChronikRow, { item }); + const link = document.querySelector('a[aria-label]'); + expect(link).not.toBeNull(); + expect(link?.getAttribute('aria-label')).toContain('A wonderful letter from grandma'); + }); + // --- 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 diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 32c1c2e6..a90502e4 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2402,6 +2402,8 @@ export interface components { * @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds. */ annotationId?: string; + /** @description Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments. */ + commentPreview?: string; }; InvitePrefillDTO: { firstName: string; -- 2.49.1 From abe8ab8668d6cc5f229a7118775e917b65edd2c9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 19:05:46 +0200 Subject: [PATCH 4/6] refactor(comment): remove dead findAnnotationIdsByIds; fix aria-label i18n; rename misleading test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `findAnnotationIdsByIds` from CommentService — no production caller exists now that DashboardService uses `findDataByIds` directly; along with its test coverage - Fix aria-label construction in ChronikRow: pass actorName to i18n message function instead of manually prepending the actor, so all locales render correctly - Rename `findDataByIds_does_not_truncate_at_exactly_120_chars` → `findDataByIds_preserves_content_at_exactly_120_chars` for accurate description Co-Authored-By: Claude Sonnet 4.6 --- .../document/comment/CommentService.java | 7 --- .../document/comment/CommentServiceTest.java | 60 +------------------ frontend/src/lib/activity/ChronikRow.svelte | 2 +- 3 files changed, 2 insertions(+), 67 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java index 2fccb84a..0dc149af 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java @@ -24,7 +24,6 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -49,12 +48,6 @@ public class CommentService { return result; } - public Map findAnnotationIdsByIds(Collection commentIds) { - return findDataByIds(commentIds).entrySet().stream() - .filter(e -> e.getValue().annotationId() != null) - .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().annotationId())); - } - private String stripAndTruncate(String html) { if (html == null || html.isBlank()) return ""; String text = Jsoup.parse(html).text().trim(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java index 897b1e34..bd514b2e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java @@ -676,7 +676,7 @@ class CommentServiceTest { } @Test - void findDataByIds_does_not_truncate_at_exactly_120_chars() { + void findDataByIds_preserves_content_at_exactly_120_chars() { UUID id = UUID.randomUUID(); String text120 = "a".repeat(120); when(commentRepository.findAllById(List.of(id))) @@ -740,64 +740,6 @@ class CommentServiceTest { assertThat(commentService.findDataByIds(List.of(id)).get(id).annotationId()).isNull(); } - // ─── 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() diff --git a/frontend/src/lib/activity/ChronikRow.svelte b/frontend/src/lib/activity/ChronikRow.svelte index 2b2cb904..22e47d13 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte +++ b/frontend/src/lib/activity/ChronikRow.svelte @@ -109,7 +109,7 @@ const rowHref: string = $derived( href={rowHref} data-variant={variant} aria-label={variant === 'comment' && item.commentPreview - ? `${actorName} ${m.chronik_comment_added({ actor: '', doc: docTitle }).trim()} — ${item.commentPreview}` + ? `${m.chronik_comment_added({ actor: actorName, doc: docTitle })} — ${item.commentPreview}` : undefined} class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}" -- 2.49.1 From 708fd9d63e14ac28b93a5aa902c65f96f458c422 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 19:47:27 +0200 Subject: [PATCH 5/6] refactor(comment): promote CommentData to top-level record in comment package Moves the nested `CommentData` record out of `CommentService` into its own `document/comment/CommentData.java` file, removing the cross-domain coupling where `DashboardService` depended on an inner type of `CommentService`. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/dashboard/DashboardService.java | 2 +- .../familienarchiv/document/comment/CommentData.java | 5 +++++ .../familienarchiv/document/comment/CommentService.java | 2 -- .../familienarchiv/dashboard/DashboardServiceTest.java | 2 +- .../familienarchiv/document/comment/CommentServiceTest.java | 6 +++--- 5 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java 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 5b5e03a1..2eacbd31 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock; import org.raddatz.familienarchiv.document.comment.CommentService; -import org.raddatz.familienarchiv.document.comment.CommentService.CommentData; +import org.raddatz.familienarchiv.document.comment.CommentData; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.document.transcription.TranscriptionService; import org.raddatz.familienarchiv.user.UserService; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java new file mode 100644 index 00000000..aefa340b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.document.comment; + +import java.util.UUID; + +public record CommentData(UUID annotationId, String preview) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java index 0dc149af..42a7662f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentService.java @@ -31,8 +31,6 @@ public class CommentService { private static final int PREVIEW_MAX_CHARS = 120; - public record CommentData(UUID annotationId, String preview) {} - private final CommentRepository commentRepository; private final UserService userService; private final NotificationService notificationService; 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 ab790806..117a3455 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock; import org.raddatz.familienarchiv.document.comment.CommentService; -import org.raddatz.familienarchiv.document.comment.CommentService.CommentData; +import org.raddatz.familienarchiv.document.comment.CommentData; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.document.transcription.TranscriptionService; import org.raddatz.familienarchiv.user.UserService; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java index bd514b2e..b41818d2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentServiceTest.java @@ -660,7 +660,7 @@ class CommentServiceTest { .thenReturn(List.of(DocumentComment.builder().id(id) .content("

Hello world

").build())); - Map result = commentService.findDataByIds(List.of(id)); + Map result = commentService.findDataByIds(List.of(id)); assertThat(result.get(id).preview()).isEqualTo("Hello world"); } @@ -710,7 +710,7 @@ class CommentServiceTest { when(commentRepository.findAllById(List.of(present, deleted))) .thenReturn(List.of(DocumentComment.builder().id(present).content("Hi").build())); - Map result = commentService.findDataByIds(List.of(present, deleted)); + Map result = commentService.findDataByIds(List.of(present, deleted)); assertThat(result).containsKey(present); assertThat(result).doesNotContainKey(deleted); @@ -724,7 +724,7 @@ class CommentServiceTest { .thenReturn(List.of(DocumentComment.builder().id(id) .annotationId(annotationId).content("Text").build())); - CommentService.CommentData data = commentService.findDataByIds(List.of(id)).get(id); + CommentData data = commentService.findDataByIds(List.of(id)).get(id); assertThat(data.annotationId()).isEqualTo(annotationId); assertThat(data.preview()).isEqualTo("Text"); -- 2.49.1 From 9ef3c82398667a91f7a5a9739f83839d5a670bbb Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 19:54:56 +0200 Subject: [PATCH 6/6] fix(review): address review blockers from PR #475 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentData.java: add @Nullable on annotationId to match codebase convention - DashboardService: isEmpty() → isBlank() for commentPreview null-guard - ChronikRow.svelte: always set aria-label on comment rows (not only when preview present) - ChronikRow.svelte.spec.ts: add test for aria-label on comment row without preview Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/dashboard/DashboardService.java | 2 +- .../familienarchiv/document/comment/CommentData.java | 3 ++- frontend/src/lib/activity/ChronikRow.svelte | 6 ++++-- frontend/src/lib/activity/ChronikRow.svelte.spec.ts | 12 ++++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) 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 2eacbd31..81c2fa83 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -149,7 +149,7 @@ public class DashboardService { UUID commentId = row.getCommentId(); CommentData commentData = commentId != null ? commentDataByComment.get(commentId) : null; UUID annotationId = commentData != null ? commentData.annotationId() : null; - String commentPreview = commentData != null && !commentData.preview().isEmpty() + String commentPreview = commentData != null && !commentData.preview().isBlank() ? commentData.preview() : null; return new ActivityFeedItemDTO( org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()), diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java index aefa340b..a0810cb4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.document.comment; +import jakarta.annotation.Nullable; import java.util.UUID; -public record CommentData(UUID annotationId, String preview) {} +public record CommentData(@Nullable UUID annotationId, String preview) {} diff --git a/frontend/src/lib/activity/ChronikRow.svelte b/frontend/src/lib/activity/ChronikRow.svelte index 22e47d13..4679d636 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte +++ b/frontend/src/lib/activity/ChronikRow.svelte @@ -108,8 +108,10 @@ const rowHref: string = $derived(
{ expect(link?.getAttribute('aria-label')).toContain('A wonderful letter from grandma'); }); + it('link still has aria-label for comment variant when commentPreview is absent', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED', + commentPreview: undefined + }; + render(ChronikRow, { item }); + const link = document.querySelector('a[aria-label]'); + expect(link).not.toBeNull(); + expect(link?.getAttribute('aria-label')).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