feat(dashboard): add kinds CSV query param to /api/dashboard/activity #302

Merged
marcel merged 11 commits from feat/issue-293-dashboard-activity-kinds-filter into main 2026-04-21 22:48:09 +02:00
18 changed files with 494 additions and 423 deletions

View File

@@ -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<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
);
}

View File

@@ -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<AuditLog, UUID> {
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<AuditLog, UUID> {
""", nativeQuery = true)
List<ActivityFeedRow> findRolledUpActivityFeed(
@Param("currentUserId") String currentUserId,
@Param("limit") int limit);
@Param("limit") int limit,
@Param("kinds") Collection<String> kinds);
@Query(value = """
SELECT

View File

@@ -17,7 +17,12 @@ public class AuditLogQueryService {
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit);
return findActivityFeed(currentUserId, limit, AuditKind.ROLLUP_ELIGIBLE);
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit, Set<AuditKind> kinds) {
List<String> kindNames = kinds.stream().map(Enum::name).toList();
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, kindNames);
}
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {

View File

@@ -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<ActivityFeedItemDTO> 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<AuditKind> kinds) {
UUID userId = SecurityUtils.requireUserId(authentication, userService);
return dashboardService.getActivity(userId, Math.min(limit, 40));
Set<AuditKind> effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds;
return dashboardService.getActivity(userId, Math.min(limit, 40), effectiveKinds);
}
}

View File

@@ -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<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit) {
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit);
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit, Set<AuditKind> kinds) {
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds);
List<UUID> docIds = rows.stream()
.map(ActivityFeedRow::getDocumentId)

View File

@@ -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<AuditKind> kinds = Set.of(AuditKind.FILE_UPLOADED);
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
.thenReturn(List.of());
List<ActivityFeedRow> 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()));
}
}

View File

@@ -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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10);
List<ActivityFeedRow> 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");

View File

@@ -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<String> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
List<ActivityFeedRow> 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<ActivityFeedRow> 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<ActivityFeedRow> 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<ActivityFeedRow> 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<ActivityFeedRow> 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<ActivityFeedRow> 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);
}
}

View File

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

View File

@@ -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<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
List<ActivityFeedItemDTO> 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<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
List<ActivityFeedItemDTO> 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<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
List<ActivityFeedItemDTO> 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<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
List<ActivityFeedItemDTO> 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);
}

View File

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

View File

@@ -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<operations['getActivity']['parameters']['query']>['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<Record<FilterValue, AuditKind[]>> = {
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 } }
})

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { page, navigating } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import { notificationStore, type NotificationItem } from '$lib/stores/notifications.svelte';
import ChronikFuerDichBox from '$lib/components/chronik/ChronikFuerDichBox.svelte';
@@ -11,7 +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';
import { applyClientFilter } from './clientFilter';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -26,14 +26,6 @@ interface Props {
const { data }: Props = $props();
// Mirror the current filter into a local state we can update on pill change.
// The effect syncs whenever the server-loaded filter changes (e.g. after goto).
// eslint-disable-next-line svelte/prefer-writable-derived -- we need this mutable for onFilterChange optimism before goto() resolves
let activeFilter = $state<FilterValue>('alle');
$effect(() => {
activeFilter = data.filter;
});
// Prefer the live SSE singleton for unread items so newly arriving mentions
// prepend without a reload. On first mount, seed from the server-loaded unread
// set if the singleton hasn't populated yet.
@@ -74,7 +66,6 @@ const unread = $derived<NotificationItem[]>(
);
async function onFilterChange(v: FilterValue) {
activeFilter = v;
const url = new URL(page.url);
if (v === 'alle') url.searchParams.delete('filter');
else url.searchParams.set('filter', v);
@@ -93,7 +84,7 @@ async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived(filterFeed(data.activityFeed, activeFilter));
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
const isEmpty = $derived(displayFeed.length === 0);
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
@@ -120,15 +111,17 @@ function retry() {
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
<div class="mt-6">
<ChronikFilterPills value={activeFilter} onChange={onFilterChange} />
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
</div>
{#if isEmpty}
<div class="mt-8">
<ChronikEmptyState variant={emptyVariant} />
</div>
{:else}
<ChronikTimeline items={displayFeed} />
{/if}
<div aria-live="polite" aria-atomic="false" aria-busy={!!navigating.type}>
{#if isEmpty}
<div class="mt-8">
<ChronikEmptyState variant={emptyVariant} />
</div>
{:else}
<ChronikTimeline items={displayFeed} />
{/if}
</div>
{/if}
</main>

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { applyClientFilter } from './clientFilter';
import type { components } from '$lib/generated/api';
type Item = components['schemas']['ActivityFeedItemDTO'];
function makeItem(overrides: Partial<Item> = {}): 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('applyClientFilter', () => {
describe('non-fuer-dich filters pass through unchanged', () => {
it('alle returns all items', () => {
const items = [makeItem(), makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'alle')).toHaveLength(2);
});
it('hochgeladen passes through (server already filtered by kinds)', () => {
const items = [makeItem({ kind: 'FILE_UPLOADED' }), makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'hochgeladen')).toHaveLength(2);
});
it('transkription passes through (server already filtered by kinds)', () => {
const items = [makeItem({ kind: 'TEXT_SAVED' })];
expect(applyClientFilter(items, 'transkription')).toHaveLength(1);
});
it('kommentare passes through (server already filtered by kinds)', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'kommentare')).toHaveLength(1);
});
});
describe('fuer-dich applies youMentioned || youParticipated predicate', () => {
it('includes items where youMentioned is true', () => {
const items = [makeItem({ youMentioned: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where youParticipated is true', () => {
const items = [makeItem({ youParticipated: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where both flags are true', () => {
const items = [makeItem({ youMentioned: true, youParticipated: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('excludes items where neither flag is set', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(0);
});
it('MENTION_CREATED without youMentioned flag is excluded', () => {
// youMentioned is set by the backend only when this event is directed at the current user.
// Unlike the old feedFilters.ts, we no longer include MENTION_CREATED unconditionally —
// that incorrectly showed mentions directed at OTHER users.
const items = [
makeItem({ kind: 'MENTION_CREATED', youMentioned: false, youParticipated: false })
];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(0);
});
it('MENTION_CREATED with youMentioned true is included', () => {
const items = [makeItem({ kind: 'MENTION_CREATED', youMentioned: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('filters mixed items to only personally relevant ones', () => {
const items = [
makeItem({ kind: 'FILE_UPLOADED' }),
makeItem({ kind: 'COMMENT_ADDED', youParticipated: true }),
makeItem({ kind: 'MENTION_CREATED', youMentioned: true }),
makeItem({ kind: 'TEXT_SAVED' })
];
const result = applyClientFilter(items, 'fuer-dich');
expect(result).toHaveLength(2);
expect(result[0].kind).toBe('COMMENT_ADDED');
expect(result[1].kind).toBe('MENTION_CREATED');
});
});
});

View File

@@ -0,0 +1,18 @@
import type { components } from '$lib/generated/api';
import type { FilterValue } from './+page.server';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
// All server-side filters (hochgeladen, transkription, kommentare) are already applied via
// kinds param — the server returns only matching items, so client-side is a no-op.
// fuer-dich is the only filter that requires client-side narrowing: youMentioned and
// youParticipated are user-scoped flags that cannot be expressed as a kinds filter.
export function applyClientFilter(
items: ActivityFeedItemDTO[],
filter: FilterValue
): ActivityFeedItemDTO[] {
if (filter === 'fuer-dich') {
return items.filter((item) => item.youMentioned || item.youParticipated);
}
return items;
}

View File

@@ -1,94 +0,0 @@
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> = {}): 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);
});
});
});

View File

@@ -1,27 +0,0 @@
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');
}
}

View File

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