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 59cf64c9..d6b1f899 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java @@ -12,6 +12,7 @@ public interface ActivityFeedRow { UUID getDocumentId(); Instant getHappenedAt(); boolean isYouMentioned(); + boolean isYouParticipated(); int getCount(); Instant getHappenedAtUntil(); } 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 59e930d3..49c998e2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -70,7 +70,9 @@ public interface AuditLogQueryRepository extends JpaRepository { CASE WHEN COUNT(*) > 1 THEN MAX(s.happened_at) ELSE NULL END AS happened_at_until, COUNT(*)::int AS count, BOOL_OR(s.kind = 'MENTION_CREATED' - AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned + AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned, + -- COMMENT_ADDED/MENTION_CREATED always have is_new_session=1, so each group has one row and MIN collapses to that row payload + MIN(s.payload::text)::jsonb AS payload FROM sessions s GROUP BY s.kind, s.actor_id, s.document_id, s.session_id ) @@ -89,6 +91,13 @@ public interface AuditLogQueryRepository extends JpaRepository { ag.document_id AS documentId, ag.happened_at AS happened_at, ag.you_mentioned AS youMentioned, + -- payload->>'commentId' matches notifications.reference_id per AuditKind.COMMENT_ADDED contract + EXISTS( + SELECT 1 FROM notifications n + WHERE n.type = 'REPLY' + AND n.recipient_id = CAST(:currentUserId AS uuid) + AND n.reference_id = (ag.payload->>'commentId')::uuid + ) AS youParticipated, ag.count AS count, ag.happened_at_until AS happenedAtUntil FROM aggregated ag 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 444c838d..dbf6a0cd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java @@ -15,6 +15,7 @@ public record ActivityFeedItemDTO( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count, @Nullable OffsetDateTime happenedAtUntil ) {} 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 b7be271f..9e2eea75 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -140,6 +140,7 @@ public class DashboardService { docTitle, row.getHappenedAt().atOffset(ZoneOffset.UTC), row.isYouMentioned(), + row.isYouParticipated(), row.getCount(), happenedAtUntil ); 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 1f38a8a0..626903b4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java @@ -14,9 +14,13 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import java.time.Instant; import java.time.OffsetDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -28,9 +32,12 @@ import static org.assertj.core.api.Assertions.assertThat; class AuditLogQueryRepositoryRolledUpTest { static final UUID USER_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + static final UUID OTHER_USER_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); static final UUID DOC_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); static final UUID OTHER_DOC_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Autowired AuditLogQueryRepository auditLogQueryRepository; @Autowired JdbcTemplate jdbcTemplate; @@ -42,6 +49,9 @@ class AuditLogQueryRepositoryRolledUpTest { jdbcTemplate.update( "INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')", USER_ID, "rollup-" + USER_ID + "@test.com"); + jdbcTemplate.update( + "INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')", + OTHER_USER_ID, "rollup-" + OTHER_USER_ID + "@test.com"); jdbcTemplate.update( "INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief A', 'a.pdf', 'PLACEHOLDER')", DOC_ID); @@ -51,17 +61,34 @@ class AuditLogQueryRepositoryRolledUpTest { } private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt) { + insertAuditEvent(actorId, docId, kind, happenedAt, Map.of()); + } + + private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt, Map payload) { + String payloadJson; + try { + payloadJson = payload.isEmpty() ? null : MAPPER.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } MapSqlParameterSource params = new MapSqlParameterSource() .addValue("kind", kind) .addValue("actor", actorId) .addValue("doc", docId) - .addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC)); + .addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC)) + .addValue("payload", payloadJson, java.sql.Types.OTHER); named().update( - "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) " - + "VALUES (:kind, :actor, :doc, :t)", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at, payload) " + + "VALUES (:kind, :actor, :doc, :t, :payload::jsonb)", params); } + private void insertReplyNotification(UUID recipientId, UUID docId, UUID commentId) { + jdbcTemplate.update( + "INSERT INTO notifications (recipient_id, type, document_id, reference_id) VALUES (?, 'REPLY', ?, ?)", + recipientId, docId, commentId); + } + @Test void rolledUpFeed_combines_same_actor_same_doc_within_2h() { insertUserAndDocs(); @@ -173,4 +200,87 @@ class AuditLogQueryRepositoryRolledUpTest { assertThat(r.getHappenedAtUntil()).isNull(); }); } + + @Test + void youParticipated_is_true_when_user_has_reply_notification_for_comment() { + insertUserAndDocs(); + UUID commentId = UUID.randomUUID(); + insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED", + Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString())); + insertReplyNotification(USER_ID, DOC_ID, commentId); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).anySatisfy(r -> + assertThat(r.isYouParticipated()).isTrue() + ); + } + + @Test + void youParticipated_is_false_for_comment_with_no_reply_notification() { + insertUserAndDocs(); + UUID commentId = UUID.randomUUID(); + insertAuditEvent(OTHER_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).allSatisfy(r -> + assertThat(r.isYouParticipated()).isFalse() + ); + } + + @Test + void youParticipated_is_false_when_comment_added_has_no_commentId_in_payload() { + insertUserAndDocs(); + insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED", + Instant.parse("2026-04-20T10:00:00Z"), Map.of()); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).allSatisfy(r -> + assertThat(r.isYouParticipated()).isFalse() + ); + } + + @Test + void youParticipated_is_false_when_reply_notification_belongs_to_other_user() { + insertUserAndDocs(); + UUID commentId = UUID.randomUUID(); + insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED", + Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString())); + insertReplyNotification(OTHER_USER_ID, DOC_ID, commentId); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).allSatisfy(r -> + assertThat(r.isYouParticipated()).isFalse() + ); + } + + @Test + void youMentioned_is_true_when_mention_created_payload_matches_current_user() { + insertUserAndDocs(); + insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED", + Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", USER_ID.toString())); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).anySatisfy(r -> + assertThat(r.isYouMentioned()).isTrue() + ); + } + + @Test + void youMentioned_is_false_when_mention_created_payload_targets_different_user() { + insertUserAndDocs(); + insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED", + Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", OTHER_USER_ID.toString())); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + + assertThat(rows).allSatisfy(r -> + assertThat(r.isYouMentioned()).isFalse() + ); + } } 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 19692ad4..30a163d1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -104,6 +104,7 @@ class DashboardServiceTest { public UUID getDocumentId() { return docId; } public Instant getHappenedAt() { return Instant.now(); } public boolean isYouMentioned() { return false; } + public boolean isYouParticipated() { return false; } public int getCount() { return 1; } public Instant getHappenedAtUntil() { return null; } }; diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index d15c12e1..c9bce6dc 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2015,6 +2015,7 @@ export interface components { /** Format: date-time */ happenedAt: string; youMentioned: boolean; + youParticipated: boolean; /** Format: int32 */ count: number; /** Format: date-time */ diff --git a/frontend/src/routes/chronik/+page.svelte b/frontend/src/routes/chronik/+page.svelte index 54b64d63..18c5b03f 100644 --- a/frontend/src/routes/chronik/+page.svelte +++ b/frontend/src/routes/chronik/+page.svelte @@ -11,6 +11,7 @@ import ChronikEmptyState from '$lib/components/chronik/ChronikEmptyState.svelte' import ChronikErrorCard from '$lib/components/chronik/ChronikErrorCard.svelte'; import type { components } from '$lib/generated/api'; import type { FilterValue } from './+page.server'; +import { filterFeed } from './feedFilters'; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; @@ -92,28 +93,7 @@ async function onMarkAllRead() { await notificationStore.markAllRead(); } -const displayFeed = $derived( - (() => { - const merged = data.activityFeed; - switch (activeFilter) { - case 'alle': - return merged; - case 'fuer-dich': - return merged.filter((i) => i.kind === 'MENTION_CREATED' || i.youMentioned); - case 'hochgeladen': - return merged.filter((i) => i.kind === 'FILE_UPLOADED'); - case 'transkription': - return merged.filter( - (i) => - i.kind === 'TEXT_SAVED' || - i.kind === 'BLOCK_REVIEWED' || - i.kind === 'ANNOTATION_CREATED' - ); - case 'kommentare': - return merged.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED'); - } - })() -); +const displayFeed = $derived(filterFeed(data.activityFeed, activeFilter)); const isEmpty = $derived(displayFeed.length === 0); const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>( diff --git a/frontend/src/routes/chronik/feedFilters.test.ts b/frontend/src/routes/chronik/feedFilters.test.ts new file mode 100644 index 00000000..391d2140 --- /dev/null +++ b/frontend/src/routes/chronik/feedFilters.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { filterFeed } from './feedFilters'; +import type { components } from '$lib/generated/api'; + +type Item = components['schemas']['ActivityFeedItemDTO']; + +function makeItem(overrides: Partial = {}): Item { + return { + kind: 'FILE_UPLOADED', + documentId: 'd1', + documentTitle: 'Brief A', + happenedAt: '2026-04-20T10:00:00Z', + youMentioned: false, + youParticipated: false, + count: 1, + actor: null, + happenedAtUntil: null, + ...overrides + }; +} + +describe('filterFeed', () => { + describe('alle', () => { + it('returns all items regardless of kind', () => { + const items = [ + makeItem({ kind: 'FILE_UPLOADED' }), + makeItem({ kind: 'COMMENT_ADDED' }), + makeItem({ kind: 'MENTION_CREATED' }) + ]; + expect(filterFeed(items, 'alle')).toHaveLength(3); + }); + }); + + describe('fuer-dich', () => { + it('includes MENTION_CREATED items', () => { + const items = [makeItem({ kind: 'MENTION_CREATED' })]; + expect(filterFeed(items, 'fuer-dich')).toHaveLength(1); + }); + + it('includes items where youMentioned is true', () => { + const items = [makeItem({ kind: 'COMMENT_ADDED', youMentioned: true })]; + expect(filterFeed(items, 'fuer-dich')).toHaveLength(1); + }); + + it('includes items where youParticipated is true', () => { + const items = [makeItem({ kind: 'COMMENT_ADDED', youParticipated: true })]; + expect(filterFeed(items, 'fuer-dich')).toHaveLength(1); + }); + + it('excludes FILE_UPLOADED with no participation', () => { + const items = [makeItem({ kind: 'FILE_UPLOADED' })]; + expect(filterFeed(items, 'fuer-dich')).toHaveLength(0); + }); + + it('excludes COMMENT_ADDED with no mention and no participation', () => { + const items = [makeItem({ kind: 'COMMENT_ADDED' })]; + expect(filterFeed(items, 'fuer-dich')).toHaveLength(0); + }); + }); + + describe('hochgeladen', () => { + it('includes only FILE_UPLOADED items', () => { + const items = [ + makeItem({ kind: 'FILE_UPLOADED' }), + makeItem({ kind: 'COMMENT_ADDED', youParticipated: true }) + ]; + expect(filterFeed(items, 'hochgeladen')).toHaveLength(1); + expect(filterFeed(items, 'hochgeladen')[0].kind).toBe('FILE_UPLOADED'); + }); + }); + + describe('transkription', () => { + it('includes TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED', () => { + const items = [ + makeItem({ kind: 'TEXT_SAVED' }), + makeItem({ kind: 'BLOCK_REVIEWED' }), + makeItem({ kind: 'ANNOTATION_CREATED' }), + makeItem({ kind: 'FILE_UPLOADED' }) + ]; + expect(filterFeed(items, 'transkription')).toHaveLength(3); + }); + }); + + describe('kommentare', () => { + it('includes COMMENT_ADDED and MENTION_CREATED', () => { + const items = [ + makeItem({ kind: 'COMMENT_ADDED' }), + makeItem({ kind: 'MENTION_CREATED' }), + makeItem({ kind: 'FILE_UPLOADED' }) + ]; + expect(filterFeed(items, 'kommentare')).toHaveLength(2); + }); + }); +}); diff --git a/frontend/src/routes/chronik/feedFilters.ts b/frontend/src/routes/chronik/feedFilters.ts new file mode 100644 index 00000000..ab66b5ab --- /dev/null +++ b/frontend/src/routes/chronik/feedFilters.ts @@ -0,0 +1,27 @@ +import type { components } from '$lib/generated/api'; +import type { FilterValue } from './+page.server'; + +type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; + +export function filterFeed( + items: ActivityFeedItemDTO[], + filter: FilterValue +): ActivityFeedItemDTO[] { + switch (filter) { + case 'alle': + return items; + case 'fuer-dich': + return items.filter( + (i) => i.kind === 'MENTION_CREATED' || i.youMentioned || i.youParticipated + ); + case 'hochgeladen': + return items.filter((i) => i.kind === 'FILE_UPLOADED'); + case 'transkription': + return items.filter( + (i) => + i.kind === 'TEXT_SAVED' || i.kind === 'BLOCK_REVIEWED' || i.kind === 'ANNOTATION_CREATED' + ); + case 'kommentare': + return items.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED'); + } +}