From d700b0a9482933d3953d41d079b679c4008a3b57 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 20:53:14 +0200 Subject: [PATCH 01/11] refactor(audit): add ROLLUP_ELIGIBLE constant to AuditKind Single source of truth for the six kinds eligible for the activity rollup feed. Co-Authored-By: Claude Sonnet 4.6 --- .../java/org/raddatz/familienarchiv/audit/AuditKind.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java index d44513b5..5a89f081 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -1,5 +1,7 @@ package org.raddatz.familienarchiv.audit; +import java.util.Set; + public enum AuditKind { /** Payload: none */ @@ -24,5 +26,10 @@ public enum AuditKind { COMMENT_ADDED, /** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */ - MENTION_CREATED, + MENTION_CREATED; + + public static final Set ROLLUP_ELIGIBLE = Set.of( + TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, + BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED + ); } -- 2.49.1 From fe7a8ed9adad08008fe9640f6f27144de8a75450 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 20:59:56 +0200 Subject: [PATCH 02/11] feat(audit): add kinds param to findRolledUpActivityFeed Filter is applied at the innermost events CTE to reduce rows entering the LAG/session CTEs. Existing callers pass ROLLUP_ELIGIBLE by default so behaviour is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../audit/AuditLogQueryRepository.java | 7 +- .../audit/AuditLogQueryService.java | 3 +- ...uditLogQueryRepositoryIntegrationTest.java | 3 +- .../AuditLogQueryRepositoryRolledUpTest.java | 116 +++++++++++++++--- 4 files changed, 109 insertions(+), 20 deletions(-) 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 9c0957b8..c502a0db 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.OffsetDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -35,8 +36,7 @@ public interface AuditLogQueryRepository extends JpaRepository { ORDER BY a.happened_at ) AS prev_happened_at FROM audit_log a - WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED', - 'BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED') + WHERE a.kind IN (:kinds) AND a.document_id IS NOT NULL ), sessions_marked AS ( @@ -108,7 +108,8 @@ public interface AuditLogQueryRepository extends JpaRepository { """, nativeQuery = true) List findRolledUpActivityFeed( @Param("currentUserId") String currentUserId, - @Param("limit") int limit); + @Param("limit") int limit, + @Param("kinds") Collection kinds); @Query(value = """ SELECT diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java index 930ef1a4..d026c9fc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -17,7 +17,8 @@ public class AuditLogQueryService { } public List findActivityFeed(UUID currentUserId, int limit) { - return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit); + return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, + AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()); } public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java index e73553c5..a3a3b3ff 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java @@ -50,7 +50,8 @@ class AuditLogQueryRepositoryIntegrationTest { "INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')" }) void findRolledUpActivityFeed_returnsAnnotationEntry() { - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10, + List.of("TEXT_SAVED","FILE_UPLOADED","ANNOTATION_CREATED","BLOCK_REVIEWED","COMMENT_ADDED","MENTION_CREATED")); assertThat(rows).hasSize(1); assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED"); 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 02e66943..5e09c187 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java @@ -36,6 +36,10 @@ class AuditLogQueryRepositoryRolledUpTest { 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"); + static final List ALL_ELIGIBLE_KINDS = List.of( + "TEXT_SAVED", "FILE_UPLOADED", "ANNOTATION_CREATED", + "BLOCK_REVIEWED", "COMMENT_ADDED", "MENTION_CREATED"); + private static final ObjectMapper MAPPER = new ObjectMapper(); @Autowired AuditLogQueryRepository auditLogQueryRepository; @@ -97,7 +101,7 @@ class AuditLogQueryRepositoryRolledUpTest { insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L)); } - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(1); ActivityFeedRow row = rows.get(0); @@ -119,7 +123,7 @@ class AuditLogQueryRepositoryRolledUpTest { insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart); insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart.plusSeconds(300)); - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(2); assertThat(rows.get(0).getCount()).isEqualTo(2); @@ -136,7 +140,7 @@ class AuditLogQueryRepositoryRolledUpTest { insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(i * 60L * 30L)); } - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(1); assertThat(rows.get(0).getCount()).isEqualTo(30); @@ -152,7 +156,7 @@ class AuditLogQueryRepositoryRolledUpTest { insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(60)); insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(120)); - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(3); assertThat(rows).allSatisfy(r -> { @@ -170,7 +174,7 @@ class AuditLogQueryRepositoryRolledUpTest { insertAuditEvent(USER_ID, DOC_ID, "METADATA_UPDATED", base.plusSeconds(60)); insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(120)); - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(1); assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED"); @@ -184,7 +188,7 @@ class AuditLogQueryRepositoryRolledUpTest { insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(300)); insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(900)); - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(2); assertThat(rows).anySatisfy(r -> { @@ -209,7 +213,7 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).anySatisfy(r -> assertThat(r.isYouParticipated()).isTrue() @@ -223,7 +227,7 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).allSatisfy(r -> assertThat(r.isYouParticipated()).isFalse() @@ -236,7 +240,7 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).allSatisfy(r -> assertThat(r.isYouParticipated()).isFalse() @@ -251,7 +255,7 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).allSatisfy(r -> assertThat(r.isYouParticipated()).isFalse() @@ -264,7 +268,7 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).anySatisfy(r -> assertThat(r.isYouMentioned()).isTrue() @@ -278,7 +282,7 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(1); assertThat(rows.get(0).getCommentId()).isEqualTo(commentId); @@ -292,7 +296,7 @@ class AuditLogQueryRepositoryRolledUpTest { Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString(), "mentionedUserId", USER_ID.toString())); - List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(1); assertThat(rows.get(0).getCommentId()).isEqualTo(commentId); @@ -304,7 +308,7 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).hasSize(1); assertThat(rows.get(0).getCommentId()).isNull(); @@ -316,10 +320,92 @@ class AuditLogQueryRepositoryRolledUpTest { 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); + List rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS); assertThat(rows).allSatisfy(r -> assertThat(r.isYouMentioned()).isFalse() ); } + + // ─── kinds filter ───────────────────────────────────────────────────────── + + @Test + void rolledUpFeed_with_single_kind_returns_only_that_kind() { + insertUserAndDocs(); + Instant base = Instant.parse("2026-04-20T10:00:00Z"); + insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base); + insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60)); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed( + USER_ID.toString(), 40, List.of("FILE_UPLOADED")); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getKind()).isEqualTo("FILE_UPLOADED"); + } + + @Test + void rolledUpFeed_with_multiple_kinds_returns_union() { + insertUserAndDocs(); + Instant base = Instant.parse("2026-04-20T10:00:00Z"); + insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base); + insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60)); + insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120)); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed( + USER_ID.toString(), 40, List.of("TEXT_SAVED", "FILE_UPLOADED")); + + assertThat(rows).hasSize(2); + assertThat(rows).extracting(ActivityFeedRow::getKind) + .containsExactlyInAnyOrder("TEXT_SAVED", "FILE_UPLOADED"); + } + + @Test + void rolledUpFeed_with_default_returns_all_six_eligible_kinds() { + insertUserAndDocs(); + Instant base = Instant.parse("2026-04-20T10:00:00Z"); + insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base); + insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60)); + insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120)); + insertAuditEvent(USER_ID, DOC_ID, "BLOCK_REVIEWED", base.plusSeconds(7300)); + insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(7360)); + insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED", base.plusSeconds(7420)); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed( + USER_ID.toString(), 40, + List.of("TEXT_SAVED", "FILE_UPLOADED", "ANNOTATION_CREATED", + "BLOCK_REVIEWED", "COMMENT_ADDED", "MENTION_CREATED")); + + assertThat(rows).hasSize(6); + } + + @Test + void rolledUpFeed_excludes_rows_not_in_filter_set() { + insertUserAndDocs(); + Instant base = Instant.parse("2026-04-20T10:00:00Z"); + insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base); + insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60)); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed( + USER_ID.toString(), 40, List.of("TEXT_SAVED")); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED"); + } + + @Test + void rolledUpFeed_rollup_still_works_when_kind_set_is_filtered_to_single_rollable_kind() { + insertUserAndDocs(); + Instant base = Instant.parse("2026-04-20T09:00:00Z"); + for (int i = 0; i < 10; i++) { + insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L)); + } + insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(20)); + + List rows = auditLogQueryRepository.findRolledUpActivityFeed( + USER_ID.toString(), 40, List.of("TEXT_SAVED")); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED"); + assertThat(rows.get(0).getCount()).isEqualTo(10); + } } -- 2.49.1 From 475e16a85d9dcd13396e61d4898286762c5f11ad Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 21:03:39 +0200 Subject: [PATCH 03/11] feat(audit): add findActivityFeed(UUID, int, Set) overload Two-arg variant delegates to three-arg with ROLLUP_ELIGIBLE so existing callers (getPulse) are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- .../audit/AuditLogQueryService.java | 8 ++- .../dashboard/AuditLogQueryServiceTest.java | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java index d026c9fc..192795bc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -17,8 +17,12 @@ public class AuditLogQueryService { } public List findActivityFeed(UUID currentUserId, int limit) { - return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, - AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()); + return findActivityFeed(currentUserId, limit, AuditKind.ROLLUP_ELIGIBLE); + } + + public List findActivityFeed(UUID currentUserId, int limit, Set kinds) { + List kindNames = kinds.stream().map(Enum::name).toList(); + return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, kindNames); } public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryServiceTest.java new file mode 100644 index 00000000..e6cff02b --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryServiceTest.java @@ -0,0 +1,54 @@ +package org.raddatz.familienarchiv.dashboard; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditLogQueryRepository; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuditLogQueryServiceTest { + + @Mock AuditLogQueryRepository queryRepository; + @InjectMocks AuditLogQueryService auditLogQueryService; + + @Test + void findActivityFeed_withKinds_passesKindNamesToRepository() { + UUID userId = UUID.randomUUID(); + Set kinds = Set.of(AuditKind.FILE_UPLOADED); + when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection())) + .thenReturn(List.of()); + + List result = auditLogQueryService.findActivityFeed(userId, 10, kinds); + + assertThat(result).isEmpty(); + verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10), + eq(List.of("FILE_UPLOADED"))); + } + + @Test + void findActivityFeed_twoArg_defaultsToAllRollupEligibleKinds() { + UUID userId = UUID.randomUUID(); + when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection())) + .thenReturn(List.of()); + + auditLogQueryService.findActivityFeed(userId, 10); + + verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10), + eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList())); + } +} -- 2.49.1 From 571ecfc626360544414771f4addd40506244d9f1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 21:05:43 +0200 Subject: [PATCH 04/11] test(dashboard): guard getPulse always uses 2-arg findActivityFeed Regression test: getPulse must never receive a kinds filter. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/DashboardServiceTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 d453f2e7..e7ceb6bd 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -6,7 +6,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditLogQueryService; +import org.raddatz.familienarchiv.audit.PulseStatsRow; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.TranscriptionBlock; @@ -16,13 +18,17 @@ import org.raddatz.familienarchiv.service.TranscriptionService; import org.raddatz.familienarchiv.service.UserService; import java.time.Instant; +import java.time.OffsetDateTime; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -158,6 +164,28 @@ class DashboardServiceTest { verify(commentService, never()).findAnnotationIdsByIds(anyList()); } + // ─── getPulse — always uses full ROLLUP_ELIGIBLE set ───────────────────── + + @Test + void pulse_uses_all_rollup_eligible_kinds_never_calls_kinds_filtered_overload() { + UUID userId = UUID.randomUUID(); + PulseStatsRow stats = new PulseStatsRow() { + public long getPages() { return 0; } + public long getAnnotated() { return 0; } + public long getTranscribed() { return 0; } + public long getUploaded() { return 0; } + public long getYourPages() { return 0; } + }; + when(auditLogQueryService.getPulseStats(any(OffsetDateTime.class), any(UUID.class))) + .thenReturn(stats); + when(auditLogQueryService.findActivityFeed(userId, 50)).thenReturn(List.of()); + + dashboardService.getPulse(userId); + + verify(auditLogQueryService).findActivityFeed(userId, 50); + verify(auditLogQueryService, never()).findActivityFeed(any(UUID.class), anyInt(), any(Set.class)); + } + private ActivityFeedRow mockFeedRow(UUID docId, String kind) { return mockFeedRow(docId, kind, null); } -- 2.49.1 From 8d16e4d975a946f54278866b4a29682d37fe8b4e Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 21:10:43 +0200 Subject: [PATCH 05/11] feat(dashboard): add kinds param to GET /api/dashboard/activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring auto-converts ?kinds=FILE_UPLOADED,TEXT_SAVED to Set. Absent or empty kinds defaults to ROLLUP_ELIGIBLE. Unknown value → 400. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/DashboardController.java | 8 ++- .../dashboard/DashboardService.java | 5 +- .../dashboard/DashboardControllerTest.java | 64 ++++++++++++++++++- .../dashboard/DashboardServiceTest.java | 16 ++--- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java index b7b34b7e..940733af 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.dashboard; import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.security.SecurityUtils; @@ -9,6 +10,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Set; import java.util.UUID; @RestController @@ -35,8 +37,10 @@ public class DashboardController { @GetMapping("/activity") public List getActivity( Authentication authentication, - @RequestParam(defaultValue = "7") int limit) { + @RequestParam(defaultValue = "7") int limit, + @RequestParam(required = false) Set kinds) { UUID userId = SecurityUtils.requireUserId(authentication, userService); - return dashboardService.getActivity(userId, Math.min(limit, 40)); + Set effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds; + return dashboardService.getActivity(userId, Math.min(limit, 40), effectiveKinds); } } 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 47f5fa13..054ea91b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.PulseStatsRow; import org.raddatz.familienarchiv.model.AppUser; @@ -110,8 +111,8 @@ public class DashboardService { ); } - public List getActivity(UUID currentUserId, int limit) { - List rows = auditLogQueryService.findActivityFeed(currentUserId, limit); + public List getActivity(UUID currentUserId, int limit, Set kinds) { + List rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds); List docIds = rows.stream() .map(ActivityFeedRow::getDocumentId) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java index 1bfc37ce..7dc70435 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.dashboard; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.security.PermissionAspect; @@ -15,10 +16,12 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Set; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -134,7 +137,7 @@ class DashboardControllerTest { UUID userId = UUID.randomUUID(); when(userService.findByEmail(any())).thenReturn( AppUser.builder().id(userId).email("u@test.com").password("pw").build()); - when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of()); + when(dashboardService.getActivity(any(UUID.class), anyInt(), any(Set.class))).thenReturn(List.of()); mockMvc.perform(get("/api/dashboard/activity")) .andExpect(status().isOk()) @@ -147,11 +150,66 @@ class DashboardControllerTest { UUID userId = UUID.randomUUID(); when(userService.findByEmail(any())).thenReturn( AppUser.builder().id(userId).email("u@test.com").password("pw").build()); - when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of()); + when(dashboardService.getActivity(any(UUID.class), anyInt(), any(Set.class))).thenReturn(List.of()); mockMvc.perform(get("/api/dashboard/activity").param("limit", "9999")) .andExpect(status().isOk()); - org.mockito.Mockito.verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40)); + verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40), any(Set.class)); + } + + // ─── GET /api/dashboard/activity — kinds param ─────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void activity_parsesKinds_fromCsvQueryParam() throws Exception { + UUID userId = UUID.randomUUID(); + when(userService.findByEmail(any())).thenReturn( + AppUser.builder().id(userId).email("u@test.com").password("pw").build()); + when(dashboardService.getActivity(any(UUID.class), anyInt(), any(Set.class))).thenReturn(List.of()); + + mockMvc.perform(get("/api/dashboard/activity") + .param("kinds", "FILE_UPLOADED", "TEXT_SAVED")) + .andExpect(status().isOk()); + + verify(dashboardService).getActivity(any(UUID.class), anyInt(), + org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.FILE_UPLOADED, AuditKind.TEXT_SAVED))); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void activity_returns400_forUnknownKindValue() throws Exception { + mockMvc.perform(get("/api/dashboard/activity").param("kinds", "INVALID_KIND")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void activity_defaults_to_rollupEligible_whenKindsAbsent() throws Exception { + UUID userId = UUID.randomUUID(); + when(userService.findByEmail(any())).thenReturn( + AppUser.builder().id(userId).email("u@test.com").password("pw").build()); + when(dashboardService.getActivity(any(UUID.class), anyInt(), any(Set.class))).thenReturn(List.of()); + + mockMvc.perform(get("/api/dashboard/activity")) + .andExpect(status().isOk()); + + verify(dashboardService).getActivity(any(UUID.class), anyInt(), + org.mockito.ArgumentMatchers.eq(AuditKind.ROLLUP_ELIGIBLE)); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void activity_treats_single_valid_kind_as_filter() throws Exception { + UUID userId = UUID.randomUUID(); + when(userService.findByEmail(any())).thenReturn( + AppUser.builder().id(userId).email("u@test.com").password("pw").build()); + when(dashboardService.getActivity(any(UUID.class), anyInt(), any(Set.class))).thenReturn(List.of()); + + mockMvc.perform(get("/api/dashboard/activity").param("kinds", "COMMENT_ADDED")) + .andExpect(status().isOk()); + + verify(dashboardService).getActivity(any(UUID.class), anyInt(), + org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.COMMENT_ADDED))); } } 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 e7ceb6bd..19a20e51 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -88,7 +88,7 @@ class DashboardServiceTest { UUID docId = UUID.randomUUID(); ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED"); - when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row, row)); + when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row, row)); Document doc = Document.builder() .id(docId).title("Familienbrief").originalFilename("f.pdf") @@ -96,7 +96,7 @@ class DashboardServiceTest { .build(); when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc)); - List items = dashboardService.getActivity(userId, 5); + List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); assertThat(items).hasSize(2); assertThat(items.get(0).documentTitle()).isEqualTo("Familienbrief"); @@ -112,13 +112,13 @@ class DashboardServiceTest { UUID commentId = UUID.randomUUID(); ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId); - when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row)); + 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.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of()); - List items = dashboardService.getActivity(userId, 5); + List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); assertThat(items).hasSize(1); assertThat(items.get(0).commentId()).isEqualTo(commentId); @@ -132,14 +132,14 @@ class DashboardServiceTest { UUID annotationId = UUID.randomUUID(); ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId); - when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row)); + 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.findAnnotationIdsByIds(List.of(commentId))) .thenReturn(Map.of(commentId, annotationId)); - List items = dashboardService.getActivity(userId, 5); + List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); assertThat(items).hasSize(1); assertThat(items.get(0).annotationId()).isEqualTo(annotationId); @@ -151,12 +151,12 @@ class DashboardServiceTest { UUID docId = UUID.randomUUID(); ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null); - when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row)); + 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); + List items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE); assertThat(items).hasSize(1); assertThat(items.get(0).commentId()).isNull(); -- 2.49.1 From 99c3106835054760c1cb8143a53ac2b6df420b3c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 21:19:16 +0200 Subject: [PATCH 06/11] feat(openapi): expose kinds param in dashboard activity spec Added @Parameter annotation so SpringDoc renders kinds as an enum-array query param; regenerated TypeScript API types. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/DashboardController.java | 5 + frontend/src/lib/generated/api.ts | 224 +----------------- 2 files changed, 10 insertions(+), 219 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java index 940733af..9c942b43 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java @@ -1,5 +1,8 @@ package org.raddatz.familienarchiv.dashboard; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.security.Permission; @@ -38,6 +41,8 @@ public class DashboardController { public List getActivity( Authentication authentication, @RequestParam(defaultValue = "7") int limit, + @Parameter(description = "Filter by audit kinds; omit for all rollup-eligible kinds", + array = @ArraySchema(schema = @Schema(implementation = AuditKind.class))) @RequestParam(required = false) Set kinds) { UUID userId = SecurityUtils.requireUserId(authentication, userService); Set effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds; diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 32095fc9..ed48f1a2 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -452,38 +452,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/{documentId}/comments": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getDocumentComments"]; - put?: never; - post: operations["postDocumentComment"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/documents/{documentId}/comments/{commentId}/replies": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["replyToDocumentComment"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/documents/{documentId}/annotations": { parameters: { query?: never; @@ -500,38 +468,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/{documentId}/annotations/{annotationId}/comments": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getAnnotationComments"]; - put?: never; - post: operations["postAnnotationComment"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["replyToAnnotationComment"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/documents/quick-upload": { parameters: { query?: never; @@ -1815,17 +1751,17 @@ export interface components { /** Format: uuid */ id?: string; displayName?: string; - /** Format: int64 */ - documentCount?: number; + personType?: string; firstName?: string; lastName?: string; + /** Format: int64 */ + documentCount?: number; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; alias?: string; notes?: string; - personType?: string; }; SenderModel: { /** Format: uuid */ @@ -2043,7 +1979,6 @@ 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. @@ -3144,81 +3079,6 @@ export interface operations { }; }; }; - getDocumentComments: { - parameters: { - query?: never; - header?: never; - path: { - documentId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["DocumentComment"][]; - }; - }; - }; - }; - postDocumentComment: { - parameters: { - query?: never; - header?: never; - path: { - documentId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateCommentDTO"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["DocumentComment"]; - }; - }; - }; - }; - replyToDocumentComment: { - parameters: { - query?: never; - header?: never; - path: { - documentId: string; - commentId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateCommentDTO"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["DocumentComment"]; - }; - }; - }; - }; listAnnotations: { parameters: { query?: never; @@ -3267,82 +3127,6 @@ export interface operations { }; }; }; - getAnnotationComments: { - parameters: { - query?: never; - header?: never; - path: { - annotationId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["DocumentComment"][]; - }; - }; - }; - }; - postAnnotationComment: { - parameters: { - query?: never; - header?: never; - path: { - documentId: string; - annotationId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateCommentDTO"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["DocumentComment"]; - }; - }; - }; - }; - replyToAnnotationComment: { - parameters: { - query?: never; - header?: never; - path: { - documentId: string; - commentId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateCommentDTO"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["DocumentComment"]; - }; - }; - }; - }; quickUpload: { parameters: { query?: never; @@ -4395,6 +4179,8 @@ export interface operations { parameters: { query?: { limit?: number; + /** @description Filter by audit kinds; omit for all rollup-eligible kinds */ + kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED")[]; }; header?: never; path?: never; -- 2.49.1 From d42293d3f53858eca439bf4c9df12ea1cdc7556b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 22:20:45 +0200 Subject: [PATCH 07/11] feat(chronik): pass kinds query param from filter pill to API Each filter pill maps to a specific set of AuditKinds sent as ?kinds= to /api/dashboard/activity. fuer-dich omits kinds so the server returns all eligible events; client-side predicate on youMentioned/youParticipated handles the final narrowing. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/chronik/+page.server.ts | 17 +++- .../src/routes/chronik/page.server.spec.ts | 92 ++++++++++++++----- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/frontend/src/routes/chronik/+page.server.ts b/frontend/src/routes/chronik/+page.server.ts index 71a03f00..89addea6 100644 --- a/frontend/src/routes/chronik/+page.server.ts +++ b/frontend/src/routes/chronik/+page.server.ts @@ -1,8 +1,13 @@ import { createApiClient } from '$lib/api.server'; -import type { components } from '$lib/generated/api'; +import type { components, operations } from '$lib/generated/api'; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type NotificationDTO = components['schemas']['NotificationDTO']; +type AuditKind = NonNullable['kinds'] extends + | (infer K)[] + | undefined + ? K + : never; export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare'; @@ -14,6 +19,13 @@ const VALID_FILTERS: FilterValue[] = [ 'kommentare' ]; +// fuer-dich stays client-side: youMentioned || youParticipated cannot be expressed as a kinds filter +const KINDS_FOR_FILTER: Partial> = { + hochgeladen: ['FILE_UPLOADED'], + transkription: ['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED'], + kommentare: ['COMMENT_ADDED', 'MENTION_CREATED'] +}; + function parseFilter(raw: string | null): FilterValue { if (raw && (VALID_FILTERS as string[]).includes(raw)) return raw as FilterValue; return 'alle'; @@ -23,9 +35,10 @@ export async function load({ fetch, url }) { const api = createApiClient(fetch); const filter = parseFilter(url.searchParams.get('filter')); const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 40); + const kinds = KINDS_FOR_FILTER[filter]; const [activityResult, unreadResult] = await Promise.allSettled([ - api.GET('/api/dashboard/activity', { params: { query: { limit } } }), + api.GET('/api/dashboard/activity', { params: { query: { limit, ...(kinds && { kinds }) } } }), api.GET('/api/notifications', { params: { query: { read: false, page: 0, size: 20 } } }) diff --git a/frontend/src/routes/chronik/page.server.spec.ts b/frontend/src/routes/chronik/page.server.spec.ts index 7a8dc135..08c3694d 100644 --- a/frontend/src/routes/chronik/page.server.spec.ts +++ b/frontend/src/routes/chronik/page.server.spec.ts @@ -13,36 +13,23 @@ function buildUrl(search = ''): URL { return new URL(`http://localhost/chronik${search}`); } +function mockSuccess() { + mockApi.GET.mockImplementation((path: string) => { + if (path === '/api/dashboard/activity') { + return Promise.resolve({ response: { ok: true }, data: [] }); + } + return Promise.resolve({ response: { ok: true }, data: { content: [] } }); + }); +} + beforeEach(() => { vi.clearAllMocks(); }); -describe('chronik/load', () => { - it('requests the activity feed with a 40-item limit', async () => { - mockApi.GET.mockImplementation((path: string) => { - if (path === '/api/dashboard/activity') { - return Promise.resolve({ response: { ok: true }, data: [] }); - } - return Promise.resolve({ response: { ok: true }, data: { content: [] } }); - }); - - await load({ fetch, url: buildUrl() } as never); - - expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { - params: { query: { limit: 40 } } - }); - }); - +describe('chronik/load — core', () => { it('requests only unread notifications for Für-dich', async () => { - mockApi.GET.mockImplementation((path: string) => { - if (path === '/api/dashboard/activity') { - return Promise.resolve({ response: { ok: true }, data: [] }); - } - return Promise.resolve({ response: { ok: true }, data: { content: [] } }); - }); - + mockSuccess(); await load({ fetch, url: buildUrl() } as never); - expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', { params: { query: { read: false, page: 0, size: 20 } } }); @@ -82,7 +69,6 @@ describe('chronik/load', () => { it('parses the filter query param, falling back to "alle" for invalid values', async () => { mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] }); - const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never); expect(validResult.filter).toBe('fuer-dich'); @@ -91,3 +77,59 @@ describe('chronik/load', () => { expect(invalidResult.filter).toBe('alle'); }); }); + +describe('chronik/load — kinds param per filter', () => { + it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => { + mockSuccess(); + await load({ fetch, url: buildUrl() } as never); + expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { + params: { query: { limit: 40 } } + }); + }); + + it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => { + mockSuccess(); + await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never); + expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { + params: { query: { limit: 40 } } + }); + }); + + it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => { + mockSuccess(); + await load({ fetch, url: buildUrl('?filter=hochgeladen') } as never); + expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { + params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } } + }); + }); + + it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => { + mockSuccess(); + await load({ fetch, url: buildUrl('?filter=transkription') } as never); + expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { + params: { + query: { + limit: 40, + kinds: expect.arrayContaining(['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED']) + } + } + }); + const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity'); + expect(call[1].params.query.kinds).toHaveLength(3); + }); + + it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => { + mockSuccess(); + await load({ fetch, url: buildUrl('?filter=kommentare') } as never); + expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { + params: { + query: { + limit: 40, + kinds: expect.arrayContaining(['COMMENT_ADDED', 'MENTION_CREATED']) + } + } + }); + const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity'); + expect(call[1].params.query.kinds).toHaveLength(2); + }); +}); -- 2.49.1 From 330c6227bca0f577763acf3d9fbccb47b53b991f Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 22:26:27 +0200 Subject: [PATCH 08/11] refactor(chronik): remove client-side filter; add aria-live/aria-busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete feedFilters.ts and its 9 tests (dead code: server now filters) - Remove activeFilter $state + $effect — read data.filter directly - fuer-dich stays client-side via youMentioned/youParticipated predicate - aria-live="polite" + aria-busy={!!navigating.type} on timeline region Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/chronik/+page.svelte | 38 ++++---- .../src/routes/chronik/feedFilters.test.ts | 94 ------------------- frontend/src/routes/chronik/feedFilters.ts | 27 ------ 3 files changed, 18 insertions(+), 141 deletions(-) delete mode 100644 frontend/src/routes/chronik/feedFilters.test.ts delete mode 100644 frontend/src/routes/chronik/feedFilters.ts diff --git a/frontend/src/routes/chronik/+page.svelte b/frontend/src/routes/chronik/+page.svelte index 18c5b03f..aa4c3679 100644 --- a/frontend/src/routes/chronik/+page.svelte +++ b/frontend/src/routes/chronik/+page.svelte @@ -1,7 +1,7 @@