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:
@@ -1,20 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
package org.raddatz.familienarchiv.dashboard;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogRepository;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class AuditLogQueryService {
|
public class AuditLogQueryService {
|
||||||
|
|
||||||
private final AuditLogQueryRepository queryRepository;
|
private final AuditLogQueryRepository queryRepository;
|
||||||
|
|
||||||
public AuditLogQueryService(AuditLogQueryRepository queryRepository) {
|
|
||||||
this.queryRepository = queryRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<UUID> findMostRecentDocumentForUser(UUID userId) {
|
public Optional<UUID> findMostRecentDocumentForUser(UUID userId) {
|
||||||
return queryRepository.findMostRecentDocumentIdByActor(userId);
|
return queryRepository.findMostRecentDocumentIdByActor(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas
|
|||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.test.context.jdbc.Sql;
|
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.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -35,4 +38,53 @@ class AuditLogQueryRepositoryIntegrationTest {
|
|||||||
|
|
||||||
assertThat(result).contains(DOC_ID);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,19 @@ class DashboardControllerTest {
|
|||||||
.andExpect(jsonPath("$.annotated").value(23));
|
.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 ─────────────────────────────────────────
|
// ─── GET /api/dashboard/activity ─────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -17,20 +17,20 @@ const { pulse }: Props = $props();
|
|||||||
|
|
||||||
{#if pulse.pages > 0}
|
{#if pulse.pages > 0}
|
||||||
<h2 class="mt-1 font-serif text-[1.375rem] leading-snug text-ink">
|
<h2 class="mt-1 font-serif text-[1.375rem] leading-snug text-ink">
|
||||||
Ihr habt <strong>{pulse.pages}</strong> Seiten bearbeitet.
|
{m.pulse_headline({ pages: pulse.pages })}
|
||||||
</h2>
|
</h2>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if pulse.yourPages > 0}
|
{#if pulse.yourPages > 0}
|
||||||
<p class="font-serif text-sm text-ink-2">
|
<p class="font-serif text-sm text-ink-2">
|
||||||
Du selbst hast {pulse.yourPages} davon bearbeitet.
|
{m.pulse_you({ pages: pulse.yourPages })}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if pulse.contributors.length > 0}
|
{#if pulse.contributors.length > 0}
|
||||||
<div class="mt-3 flex items-center gap-1">
|
<div class="mt-3 flex items-center gap-1">
|
||||||
<p class="mr-1 font-sans text-[11px] text-ink-3">{m.pulse_contributors()}</p>
|
<p class="mr-1 font-sans text-[11px] text-ink-3">{m.pulse_contributors()}</p>
|
||||||
{#each pulse.contributors as c (c.initials)}
|
{#each pulse.contributors as c (c.initials + c.color)}
|
||||||
<span
|
<span
|
||||||
class="-ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full font-sans text-[11px] font-bold text-white ring-2 ring-white first:ml-0"
|
class="-ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full font-sans text-[11px] font-bold text-white ring-2 ring-white first:ml-0"
|
||||||
style="background:{c.color}"
|
style="background:{c.color}"
|
||||||
|
|||||||
@@ -4451,3 +4451,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||||
|
export type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||||
|
export type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||||
|
|||||||
@@ -151,7 +151,9 @@ const greetingText = $derived.by(() => {
|
|||||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||||
<DropZone />
|
{#if data.canWrite}
|
||||||
|
<DropZone />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user