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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 23:18:35 +02:00
parent ab3a026feb
commit 8df0c3a1ef
5 changed files with 78 additions and 27 deletions

View File

@@ -38,7 +38,15 @@ public class AuditLogQueryService {
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
}
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())

View File

@@ -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<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
return buildResult(sortByFirstReceiver(results, dir), text);
}
if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec);
List<Document> 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<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
return buildResult(results, text);
}
private DocumentSearchResult buildResult(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
List<DocumentSearchItem> 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<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
if (docIds.isEmpty()) return Map.of();
Map<UUID, Integer> result = new HashMap<>();
for (CompletionStatsRow row : transcriptionBlockRepository.findCompletionStatsForDocuments(docIds)) {
result.put(row.getDocumentId(), row.getCompletionPercentage());
}
return result;
}
private Sort resolveSort(DocumentSort sort, String dir) {

View File

@@ -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"));
}

View File

@@ -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);
}
}

View File

@@ -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
}