From 8df0c3a1ef3af097ab8d9bff129e4249ec909d00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:18:35 +0200 Subject: [PATCH] feat(service): assemble DocumentSearchItem in DocumentService with completion and contributors DocumentService.searchDocuments now fetches completion percentages and recent contributors per document and zips them into DocumentSearchItem records. Update affected tests to use the new items-based result shape. Co-Authored-By: Claude Sonnet 4.6 --- .../audit/AuditLogQueryService.java | 10 +++- .../service/DocumentService.java | 46 ++++++++++++++++--- .../controller/DocumentControllerTest.java | 16 ++++--- .../service/DocumentServiceSortTest.java | 12 +++-- .../service/DocumentServiceTest.java | 21 +++++---- 5 files changed, 78 insertions(+), 27 deletions(-) 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 da887b05..c007f4bb 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -38,7 +38,15 @@ public class AuditLogQueryService { public Map> findContributorsPerDocument(List documentIds) { if (documentIds.isEmpty()) return Map.of(); - List rows = queryRepository.findContributorsPerDocument(documentIds); + return toContributorMap(queryRepository.findContributorsPerDocument(documentIds)); + } + + public Map> findRecentContributorsPerDocument(List documentIds) { + if (documentIds.isEmpty()) return Map.of(); + return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds)); + } + + private Map> toContributorMap(List rows) { Map> result = new LinkedHashMap<>(); for (ContributorRow row : rows) { result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>()) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index c27afdd9..c2f58389 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -3,8 +3,11 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; @@ -18,7 +21,9 @@ import org.raddatz.familienarchiv.model.ScriptType; import org.raddatz.familienarchiv.model.TrainingLabel; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.repository.CompletionStatsRow; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; @@ -59,6 +64,8 @@ public class DocumentService { private final DocumentVersionService documentVersionService; private final AnnotationService annotationService; private final AuditService auditService; + private final TranscriptionBlockRepository transcriptionBlockRepository; + private final AuditLogQueryService auditLogQueryService; public record StoreResult(Document document, boolean isNew) {} @@ -344,7 +351,7 @@ public class DocumentService { if (hasText) { rankedIds = documentRepository.findRankedIdsByFts(text); - if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of()); + if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); } boolean useOrLogic = tagOperator == TagOperator.OR; @@ -363,13 +370,11 @@ public class DocumentService { // generates an INNER JOIN that silently drops documents with null sender/receivers. if (sort == DocumentSort.RECEIVER) { List results = documentRepository.findAll(spec); - List sorted = sortByFirstReceiver(results, dir); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); + return buildResult(sortByFirstReceiver(results, dir), text); } if (sort == DocumentSort.SENDER) { List results = documentRepository.findAll(spec); - List sorted = sortBySender(results, dir); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); + return buildResult(sortBySender(results, dir), text); } // RELEVANCE: default when text present and no explicit sort given @@ -382,12 +387,39 @@ public class DocumentService { .sorted(Comparator.comparingInt( doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE))) .toList(); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); + return buildResult(sorted, text); } Sort springSort = resolveSort(sort, dir); List results = documentRepository.findAll(spec, springSort); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text)); + return buildResult(results, text); + } + + private DocumentSearchResult buildResult(List documents, String text) { + List colorResolved = resolveDocumentTagColors(documents); + Map matchData = enrichWithMatchData(colorResolved, text); + + List docIds = colorResolved.stream().map(Document::getId).toList(); + Map completionByDoc = fetchCompletionPercentages(docIds); + Map> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds); + + List items = colorResolved.stream().map(doc -> new DocumentSearchItem( + doc, + matchData.getOrDefault(doc.getId(), SearchMatchData.empty()), + completionByDoc.getOrDefault(doc.getId(), 0), + contributorsByDoc.getOrDefault(doc.getId(), List.of()) + )).toList(); + + return DocumentSearchResult.of(items); + } + + private Map fetchCompletionPercentages(List docIds) { + if (docIds.isEmpty()) return Map.of(); + Map result = new HashMap<>(); + for (CompletionStatsRow row : transcriptionBlockRepository.findCompletionStatsForDocuments(docIds)) { + result.put(row.getDocumentId(), row.getCompletionPercentage()); + } + return result; } private Sort resolveSort(DocumentSort sort, String dir) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index f8aaf5dd..6b8b5fbc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -25,10 +25,12 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.raddatz.familienarchiv.dto.DocumentSearchItem; +import org.raddatz.familienarchiv.dto.SearchMatchData; + import java.time.LocalDateTime; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -115,12 +117,12 @@ class DocumentControllerTest { mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(0)) - .andExpect(jsonPath("$.documents").isArray()); + .andExpect(jsonPath("$.items").isArray()); } @Test @WithMockUser - void search_responseBodyContainsMatchDataKey() throws Exception { + void search_responseBodyItemsContainMatchData() throws Exception { UUID docId = UUID.randomUUID(); Document doc = Document.builder() .id(docId) @@ -128,15 +130,15 @@ class DocumentControllerTest { .originalFilename("brief.pdf") .status(DocumentStatus.UPLOADED) .build(); - var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData( + var matchData = new SearchMatchData( "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) - .thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData))); + .thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of())))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.matchData").isMap()) - .andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet") + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet") .value("Er schrieb einen langen Brief")); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java index 49726999..2deee90f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java @@ -5,11 +5,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; @@ -30,6 +32,8 @@ class DocumentServiceSortTest { @Mock TagService tagService; @Mock DocumentVersionService documentVersionService; @Mock AnnotationService annotationService; + @Mock AuditLogQueryService auditLogQueryService; + @Mock TranscriptionBlockRepository transcriptionBlockRepository; @InjectMocks DocumentService documentService; // ─── searchDocuments — DATE sort ────────────────────────────────────────── @@ -56,8 +60,8 @@ class DocumentServiceSortTest { "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null); // Expect: date order (newer 1960 first), NOT rank order (older 1940 first) - assertThat(result.documents()).hasSize(2); - assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first } // ─── searchDocuments — RELEVANCE sort ───────────────────────────────────── @@ -78,7 +82,7 @@ class DocumentServiceSortTest { "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); // Expect: rank order restored (id1 first) - assertThat(result.documents().get(0).getId()).isEqualTo(id1); + assertThat(result.items().get(0).document().getId()).isEqualTo(id1); } @Test @@ -96,6 +100,6 @@ class DocumentServiceSortTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, null, null, null); - assertThat(result.documents().get(0).getId()).isEqualTo(id1); + assertThat(result.items().get(0).document().getId()).isEqualTo(id1); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index dc23df1a..396a7362 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -7,7 +7,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; @@ -20,6 +22,7 @@ import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -51,6 +54,8 @@ class DocumentServiceTest { @Mock DocumentVersionService documentVersionService; @Mock AnnotationService annotationService; @Mock AuditService auditService; + @Mock AuditLogQueryService auditLogQueryService; + @Mock TranscriptionBlockRepository transcriptionBlockRepository; @InjectMocks DocumentService documentService; // ─── deleteDocument ─────────────────────────────────────────────────────── @@ -1298,8 +1303,8 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); - assertThat(result.documents()).hasSize(2); - assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender"); + assertThat(result.items()).hasSize(2); + assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender"); } // ─── searchDocuments — RECEIVER sort, empty receivers ─────────────────────── @@ -1318,7 +1323,7 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null); - assertThat(result.documents()).extracting(Document::getTitle) + assertThat(result.items()).extracting(item -> item.document().getTitle()) .containsExactly("Has Receiver", "No Receivers"); } @@ -1341,7 +1346,7 @@ class DocumentServiceTest { null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); // null lastName should sort to end (treated as empty), not before "smith" (as "null") - assertThat(result.documents()).extracting(Document::getTitle) + assertThat(result.items()).extracting(item -> item.document().getTitle()) .containsExactly("smith doc", "Null lastname doc"); } @@ -1362,8 +1367,8 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); - assertThat(result.matchData()).containsKey(docId); - SearchMatchData md = result.matchData().get(docId); + assertThat(result.items()).hasSize(1); + SearchMatchData md = result.items().get(0).matchData(); assertThat(md.titleOffsets()).hasSize(1); assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0 } @@ -1376,7 +1381,7 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, null, null, null); - assertThat(result.matchData()).isEmpty(); + assertThat(result.items()).isEmpty(); } @Test @@ -1395,7 +1400,7 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); - SearchMatchData md = result.matchData().get(docId); + SearchMatchData md = result.items().get(0).matchData(); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13 }