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