From b662117e554c4b8c0f312cfcebfef95617d592e9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:40:56 +0200 Subject: [PATCH] feat(bulk-edit): add GET /api/documents/ids endpoint READ_ALL-gated endpoint returning all document UUIDs matching the same filter parameters as /search, ignoring page/size. Powers the "Alle X editieren" fast path so the bulk-edit page can replace the selection with every match in one round-trip. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 16 +++++++++ .../service/DocumentService.java | 29 ++++++++++++++++ .../controller/DocumentControllerTest.java | 33 +++++++++++++++++++ .../service/DocumentServiceTest.java | 26 +++++++++++++++ 4 files changed, 104 insertions(+) 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 5d45300a..5dc6a591 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -281,6 +281,22 @@ public class DocumentController { return new BulkEditResult(updated, errors); } + @GetMapping("/ids") + @RequirePermission(Permission.READ_ALL) + public List getDocumentIds( + @RequestParam(required = false) String q, + @RequestParam(required = false) LocalDate from, + @RequestParam(required = false) LocalDate to, + @RequestParam(required = false) UUID senderId, + @RequestParam(required = false) UUID receiverId, + @RequestParam(required = false, name = "tag") List tags, + @RequestParam(required = false) String tagQ, + @RequestParam(required = false) DocumentStatus status, + @RequestParam(required = false) String tagOp) { + TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; + return documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator); + } + @PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE) @RequirePermission(Permission.READ_ALL) public List batchMetadata(@RequestBody BatchMetadataRequest request) { 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 c5832b0b..0a4e52e4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -350,6 +350,35 @@ public class DocumentService { return documentRepository.save(doc); } + /** + * Returns all document IDs matching the given filter parameters, ignoring + * pagination. Used by the bulk-edit "Alle X editieren" fast path so the + * frontend can replace the selection with every match across pages in one + * round-trip. + */ + public List findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, + List tags, String tagQ, DocumentStatus status, TagOperator tagOperator) { + boolean hasText = StringUtils.hasText(text); + List rankedIds = null; + if (hasText) { + rankedIds = documentRepository.findRankedIdsByFts(text); + if (rankedIds.isEmpty()) return List.of(); + } + boolean useOrLogic = tagOperator == TagOperator.OR; + List> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); + + Specification textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null; + Specification spec = Specification.where(textSpec) + .and(isBetween(from, to)) + .and(hasSender(sender)) + .and(hasReceiver(receiver)) + .and(hasTags(expandedTagSets, useOrLogic)) + .and(hasTagPartial(tagQ)) + .and(hasStatus(status)); + + return documentRepository.findAll(spec).stream().map(Document::getId).toList(); + } + /** * Returns lightweight summaries (id, title, server PDF URL) for the given * document IDs. Unknown IDs are silently dropped — the consumer is the 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 a4075835..fed57756 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -1016,6 +1016,39 @@ class DocumentControllerTest { verify(documentService).applyBulkEditToDocument(eq(id2), any()); } + // ─── GET /api/documents/ids ────────────────────────────────────────────── + + @Test + void getDocumentIds_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/ids")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getDocumentIds_returns200_andDelegatesToService() throws Exception { + UUID id = UUID.randomUUID(); + when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(List.of(id)); + + mockMvc.perform(get("/api/documents/ids")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0]").value(id.toString())); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getDocumentIds_passesSenderIdParamToService() throws Exception { + UUID senderId = UUID.randomUUID(); + when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any())) + .thenReturn(List.of()); + + mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString())) + .andExpect(status().isOk()); + + verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()); + } + // ─── POST /api/documents/batch-metadata ────────────────────────────────── @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 034a30bf..f90c725f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -2089,6 +2089,32 @@ class DocumentServiceTest { assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation"); } + // ─── findIdsForFilter ──────────────────────────────────────────────────── + + @Test + void findIdsForFilter_returnsAllMatchingIds_uncapped() { + Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build(); + Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build(); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) + .thenReturn(List.of(d1, d2)); + + List result = documentService.findIdsForFilter( + null, null, null, null, null, null, null, null, null); + + assertThat(result).containsExactly(d1.getId(), d2.getId()); + } + + @Test + void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() { + when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of()); + + List result = documentService.findIdsForFilter( + "xyz", null, null, null, null, null, null, null, null); + + assertThat(result).isEmpty(); + verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class)); + } + // ─── batchMetadata ─────────────────────────────────────────────────────── @Test