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 }