From 16614d1bfb2dc8124c53a393679b641a5eb01b09 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:05:49 +0200 Subject: [PATCH] feat(audit): add findRecentContributorsForDocuments query (max 4, recency order) Adds a window-function query that returns at most 4 contributors per document ordered by most-recent activity. Used by DocumentService to populate the contributors field in DocumentSearchItem (issue #281). Co-Authored-By: Claude Sonnet 4.6 --- .../audit/AuditLogQueryRepository.java | 34 +++++++ ...ditLogQueryRepositoryContributorsTest.java | 94 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java 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 adc933d0..2d725c69 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -106,4 +106,38 @@ public interface AuditLogQueryRepository extends JpaRepository { ORDER BY a.document_id, MIN(a.happened_at) """, nativeQuery = true) List findContributorsPerDocument(@Param("documentIds") List documentIds); + + @Query(value = """ + SELECT + ranked.document_id AS documentId, + ranked.actorInitials AS actorInitials, + ranked.actorColor AS actorColor, + ranked.actorName AS actorName + FROM ( + SELECT + a.document_id, + CASE + WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL + THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1)) + WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1)) + WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1)) + ELSE '?' + END AS actorInitials, + COALESCE(u.color, '') AS actorColor, + NULLIF(CONCAT_WS(' ', u.first_name, u.last_name), '') AS actorName, + ROW_NUMBER() OVER ( + PARTITION BY a.document_id + ORDER BY MAX(a.happened_at) DESC + ) AS rn + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_id + WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED') + AND a.document_id IN :documentIds + AND a.actor_id IS NOT NULL + GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color + ) ranked + WHERE ranked.rn <= 4 + ORDER BY ranked.document_id, ranked.rn + """, nativeQuery = true) + List findRecentContributorsForDocuments(@Param("documentIds") List documentIds); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java new file mode 100644 index 00000000..1126b447 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java @@ -0,0 +1,94 @@ +package org.raddatz.familienarchiv.dashboard; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.audit.AuditLogQueryRepository; +import org.raddatz.familienarchiv.audit.ContributorRow; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class AuditLogQueryRepositoryContributorsTest { + + static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + static final UUID USER_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000001"); + static final UUID USER_B = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000002"); + static final UUID USER_C = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000003"); + static final UUID USER_D = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000004"); + static final UUID USER_E = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000005"); + + @Autowired AuditLogQueryRepository auditLogQueryRepository; + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#f00')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void findRecentContributors_returns_contributor_with_initials_and_color() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(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"); + } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#aaa')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000002', true, 'b@test.com', 'pw', 'Ben', 'Wolf', '#bbb')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000003', true, 'c@test.com', 'pw', 'Clara', 'Zorn', '#ccc')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000004', true, 'd@test.com', 'pw', 'Dirk', 'Ott', '#ddd')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000005', true, 'e@test.com', 'pw', 'Eva', 'Kern', '#eee')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '5 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '4 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000003', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '3 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000004', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '2 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '1 hour')" + }) + void findRecentContributors_limits_to_4_most_recent() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID)); + + assertThat(rows).hasSize(4); + // Most recent first: E, D, C, B (A is 5th, excluded) + assertThat(rows.get(0).getActorInitials()).isEqualTo("EK"); + assertThat(rows.get(1).getActorInitials()).isEqualTo("DO"); + assertThat(rows.get(2).getActorInitials()).isEqualTo("CZ"); + assertThat(rows.get(3).getActorInitials()).isEqualTo("BW"); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')" + }) + void findRecentContributors_returns_empty_when_no_audit_entries() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID)); + + assertThat(rows).isEmpty(); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')", + // Deleted user: ON DELETE SET NULL makes actor_id NULL — query excludes these rows + "INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('TEXT_SAVED', NULL, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void findRecentContributors_excludes_entries_from_deleted_users() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID)); + + assertThat(rows).isEmpty(); + } +}