fix(dashboard): i18n, a11y, security, and type-safety fixes from PR review

- Use @RequiredArgsConstructor in AuditLogQueryService; remove unused import
- Add 401/403 tests for /activity endpoint
- Add getPulseStats and findContributorsPerDocument integration tests
- Use m.pulse_headline/pulse_you in FamilyPulse; composite avatar keys
- Replace hover:text-accent with hover:text-ink in ActivityFeed (WCAG AA)
- Localise "Alle →" link with feed_show_all key + aria-label
- Gate DropZone behind {#if data.canWrite}
- Export DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO from api.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 21:43:16 +02:00
parent 148710f2ed
commit 2bb08b6877
6 changed files with 77 additions and 9 deletions

View File

@@ -1,20 +1,17 @@
package org.raddatz.familienarchiv.dashboard;
import org.raddatz.familienarchiv.audit.AuditLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class AuditLogQueryService {
private final AuditLogQueryRepository queryRepository;
public AuditLogQueryService(AuditLogQueryRepository queryRepository) {
this.queryRepository = queryRepository;
}
public Optional<UUID> findMostRecentDocumentForUser(UUID userId) {
return queryRepository.findMostRecentDocumentIdByActor(userId);
}

View File

@@ -9,6 +9,9 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -35,4 +38,53 @@ class AuditLogQueryRepositoryIntegrationTest {
assertThat(result).contains(DOC_ID);
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"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 findDedupedActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getHappenedAt()).isNotNull();
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"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}')",
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"blockId\":\"ccc\",\"pageNumber\":1}')",
"INSERT INTO audit_log (kind, document_id) VALUES ('FILE_UPLOADED', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void getPulseStats_countsAnnotationsTranscriptionsAndUploads() {
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC).minusDays(7);
PulseStatsRow stats = auditLogQueryRepository.getPulseStats(weekStart, USER_ID.toString());
assertThat(stats.getAnnotated()).isEqualTo(1);
assertThat(stats.getTranscribed()).isEqualTo(1);
assertThat(stats.getUploaded()).isEqualTo(1);
assertThat(stats.getYourPages()).isGreaterThanOrEqualTo(1);
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw', 'Anna', 'Meier', '#f00')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void findContributorsPerDocument_returnsContributorWithInitialsAndColor() {
List<ContributorRow> rows = auditLogQueryRepository.findContributorsPerDocument(List.of(DOC_ID));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getActorInitials()).isEqualTo("AM");
assertThat(rows.get(0).getActorColor()).isEqualTo("#f00");
}
}

View File

@@ -113,6 +113,19 @@ class DashboardControllerTest {
.andExpect(jsonPath("$.annotated").value(23));
}
@Test
void activity_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void activity_returns403_whenUserHasNoPermissions() throws Exception {
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isForbidden());
}
// ─── GET /api/dashboard/activity ─────────────────────────────────────────
@Test