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/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..81c2fa83 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.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().isBlank() + ? 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/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..a0810cb4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/comment/CommentData.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.document.comment; + +import jakarta.annotation.Nullable; +import java.util.UUID; + +public record CommentData(@Nullable 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 33af36ce..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 @@ -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; @@ -28,21 +29,29 @@ import java.util.UUID; @RequiredArgsConstructor public class CommentService { + private static final int PREVIEW_MAX_CHARS = 120; + 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; } + 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/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java index 472bbaf3..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,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.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 ───────────────────── 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..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 @@ -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,62 +645,99 @@ class CommentServiceTest { verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any()); } - // ─── findAnnotationIdsByIds ─────────────────────────────────────────────── + // ─── findDataByIds ──────────────────────────────────────────────────────── @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(); + void findDataByIds_returns_empty_map_when_input_is_empty() { + assertThat(commentService.findDataByIds(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() - )); + 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())); - assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing))) - .containsOnly(java.util.Map.entry(known, knownAnnotation)) - .doesNotContainKey(missing); + Map result = commentService.findDataByIds(List.of(id)); + + assertThat(result.get(id).preview()).isEqualTo("Hello world"); } @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() - )); + 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.findAnnotationIdsByIds(List.of(legacy, block))) - .containsOnly(java.util.Map.entry(block, annotation)) - .doesNotContainKey(legacy); + assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120); + } + + @Test + void findDataByIds_preserves_content_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())); + + 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(); } private void stubBlock(UUID docId, UUID blockId) { diff --git a/frontend/src/lib/activity/ChronikRow.svelte b/frontend/src/lib/activity/ChronikRow.svelte index 91651de6..4679d636 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte +++ b/frontend/src/lib/activity/ChronikRow.svelte @@ -108,6 +108,11 @@ const rowHref: string = $derived( @@ -159,13 +164,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..0afccf9c 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikRow.svelte.spec.ts @@ -186,6 +186,61 @@ 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'); + }); + + 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 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;