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 + ); } 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..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,7 +17,12 @@ public class AuditLogQueryService { } public List findActivityFeed(UUID currentUserId, int limit) { - return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit); + 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/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java index b7b34b7e..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,6 +1,10 @@ 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; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.security.SecurityUtils; @@ -9,6 +13,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 +40,12 @@ public class DashboardController { @GetMapping("/activity") public List getActivity( Authentication authentication, - @RequestParam(defaultValue = "7") int limit) { + @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); - 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/audit/AuditLogQueryServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java new file mode 100644 index 00000000..4c469deb --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java @@ -0,0 +1,50 @@ +package org.raddatz.familienarchiv.audit; + +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 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())); + } +} 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..c29a2a14 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryRolledUpTest.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.dashboard; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditLogQueryRepository; import org.raddatz.familienarchiv.config.FlywayConfig; import org.springframework.beans.factory.annotation.Autowired; @@ -36,6 +37,9 @@ 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 = + AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList(); + 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); + } } 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..786eebfb 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())).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())).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()); + } + + // ─── 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())).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())).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())).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 d453f2e7..19a20e51 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; @@ -82,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") @@ -90,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"); @@ -106,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); @@ -126,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); @@ -145,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(); @@ -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); } 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; 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.svelte b/frontend/src/routes/chronik/+page.svelte index 18c5b03f..719a1ab3 100644 --- a/frontend/src/routes/chronik/+page.svelte +++ b/frontend/src/routes/chronik/+page.svelte @@ -1,7 +1,7 @@