From c2b5008c660cd9836bdeba3611a42a9351f54d76 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:13:06 +0200 Subject: [PATCH] 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 --- .../controller/DocumentController.java | 8 +++- .../familienarchiv/model/DocumentSort.java | 5 ++ .../service/DocumentService.java | 46 +++++++++++++++++-- .../controller/DocumentControllerTest.java | 13 ++++-- .../service/DocumentServiceTest.java | 4 +- 5 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index f661e8aa..57a0f437 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -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 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 --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java new file mode 100644 index 00000000..6456ef54 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.model; + +public enum DocumentSort { + DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE +} 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 fe5826ee..3f15cc03 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -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 searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, DocumentStatus status) { + public List searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) { Specification 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 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 sortByFirstReceiver(List documents, String dir) { + boolean ascending = "ASC".equalsIgnoreCase(dir); + Comparator 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 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 1166d873..50c31adb 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -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 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 fb01c7a2..5a11ba6d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -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)); }