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