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;