feat(chronik): deep-link mentions and comments to the specific comment #301

Merged
marcel merged 11 commits from feat/issue-300-chronik-mention-deep-link into main 2026-04-21 19:06:19 +02:00
17 changed files with 413 additions and 8 deletions

View File

@@ -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();
}

View File

@@ -99,7 +99,8 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
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

View File

@@ -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
) {}

View File

@@ -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<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
@@ -125,6 +127,15 @@ public class DashboardService {
log.warn("Activity: failed to bulk-load document titles", e);
}
List<UUID> commentIds = rows.stream()
.map(ActivityFeedRow::getCommentId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<UUID, UUID> 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();
}

View File

@@ -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<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
if (commentIds == null || commentIds.isEmpty()) return Map.of();
Map<UUID, UUID> result = new HashMap<>();
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
}
return result;
}
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
return withRepliesAndMentions(roots);

View File

@@ -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<ActivityFeedRow> 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<ActivityFeedRow> 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<ActivityFeedRow> 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();

View File

@@ -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<ActivityFeedItemDTO> 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<ActivityFeedItemDTO> 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<ActivityFeedItemDTO> 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; }
};
}
}

View File

@@ -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()

View File

@@ -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<typeof stream.markRead>[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);
}

View File

@@ -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> = {}): 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<HTMLButtonElement>('button[aria-haspopup="true"]')!;
bellButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
const notifButton = document.querySelector<HTMLButtonElement>('[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');
});
});
});

View File

@@ -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);
}
</script>

View File

@@ -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 <a>', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })],

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/utils/time';
import { buildCommentHref } from '$lib/utils/commentDeepLink';
import type { components } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -96,10 +97,16 @@ const verbParts: { before: string; after: string } = $derived.by(() => {
after: verbText.slice(idx + SENTINEL.length)
};
});
const rowHref: string = $derived(
item.commentId
? buildCommentHref(item.documentId, item.commentId, item.annotationId ?? null)
: `/documents/${item.documentId}`
);
</script>
<a
href="/documents/{item.documentId}"
href={rowHref}
data-variant={variant}
class="group flex items-start gap-3 rounded-sm p-3 transition-colors hover:bg-canvas 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' : ''}"

View File

@@ -133,6 +133,59 @@ describe('ChronikRow', () => {
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

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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;
}