feat(search): add sort param (DATE/TITLE/SENDER/RECEIVER/UPLOAD_DATE) and tagQ filter

- DocumentSort enum validated by Spring MVC (400 for unknown values)
- SENDER sort uses Spring Data Sort on sender.lastName/firstName
- RECEIVER sort uses in-memory sort by first receiver alphabetically
- UPLOAD_DATE sort uses createdAt; default sort is DATE DESC
- tagQ param wired to hasTagPartial specification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-06 13:13:06 +02:00
parent beca2d463a
commit c2b5008c66
5 changed files with 66 additions and 10 deletions

View File

@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentSort;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.Permission;
@@ -193,8 +194,11 @@ public class DocumentController {
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
@RequestParam(required = false) String tagQ,
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir));
}
// --- VERSIONS ---

View File

@@ -0,0 +1,5 @@
package org.raddatz.familienarchiv.model;
public enum DocumentSort {
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE
}

View File

@@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentSort;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
@@ -26,6 +27,7 @@ import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -280,16 +282,54 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
Specification<Document> spec = Specification.where(hasText(text))
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(tags))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
// Neueste zuerst (nach Erstellungsdatum)
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
Sort springSort = resolveSort(sort, dir);
if (sort == DocumentSort.RECEIVER) {
List<Document> results = documentRepository.findAll(spec);
return sortByFirstReceiver(results, dir);
}
return documentRepository.findAll(spec, springSort);
}
private Sort resolveSort(DocumentSort sort, String dir) {
Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
if (sort == null || sort == DocumentSort.DATE) {
return Sort.by(direction, "documentDate");
}
return switch (sort) {
case TITLE -> Sort.by(direction, "title");
case SENDER -> Sort.by(direction, "sender.lastName").and(Sort.by(direction, "sender.firstName"));
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
default -> Sort.by(direction, "documentDate");
};
}
private List<Document> sortByFirstReceiver(List<Document> documents, String dir) {
boolean ascending = "ASC".equalsIgnoreCase(dir);
Comparator<String> nullSafeComparator = (a, b) -> {
if (a.isEmpty() && b.isEmpty()) return 0;
if (a.isEmpty()) return 1;
if (b.isEmpty()) return -1;
return ascending ? a.compareTo(b) : b.compareTo(a);
};
return documents.stream()
.sorted(Comparator.comparing(this::firstReceiverSortKey, nullSafeComparator))
.toList();
}
private String firstReceiverSortKey(Document doc) {
return doc.getReceivers().stream()
.min(Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName))
.map(p -> p.getLastName() + " " + p.getFirstName())
.orElse("");
}
// 2. SPEZIALITÄT: Der Schriftwechsel

View File

@@ -58,7 +58,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search"))
@@ -68,13 +68,13 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED)))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED));
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any());
}
@Test
@@ -84,6 +84,13 @@ class DocumentControllerTest {
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_withInvalidSort_returns400() throws Exception {
mockMvc.perform(get("/api/documents/search").param("sort", "GARBAGE"))
.andExpect(status().isBadRequest());
}
// ─── POST /api/documents ─────────────────────────────────────────────────
@Test

View File

@@ -1199,7 +1199,7 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
documentService.searchDocuments(null, null, null, null, null, null, DocumentStatus.REVIEWED);
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
@@ -1209,7 +1209,7 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
documentService.searchDocuments(null, null, null, null, null, null, null);
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}