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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-25 14:40:56 +02:00
parent d251806e72
commit b662117e55
4 changed files with 104 additions and 0 deletions

View File

@@ -281,6 +281,22 @@ public class DocumentController {
return new BulkEditResult(updated, errors);
}
@GetMapping("/ids")
@RequirePermission(Permission.READ_ALL)
public List<UUID> 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<String> 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<DocumentBatchSummary> batchMetadata(@RequestBody BatchMetadataRequest request) {

View File

@@ -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<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
}
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
Specification<Document> 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

View File

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

View File

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