feat(bulk-edit): add POST /api/documents/batch-metadata endpoint
READ_ALL-gated batch endpoint returning lightweight summaries (id, title, server PDF URL) for the bulk-edit page's left strip. Unknown IDs are silently dropped — missing previews would be obvious to the user already. Refs #225 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,9 +18,11 @@ import jakarta.validation.constraints.Min;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
|
||||||
import org.raddatz.familienarchiv.dto.BulkEditError;
|
import org.raddatz.familienarchiv.dto.BulkEditError;
|
||||||
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
@@ -279,6 +281,15 @@ public class DocumentController {
|
|||||||
return new BulkEditResult(updated, errors);
|
return new BulkEditResult(updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
|
public List<DocumentBatchSummary> batchMetadata(@RequestBody BatchMetadataRequest request) {
|
||||||
|
if (request == null || request.ids() == null || request.ids().isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
|
||||||
|
}
|
||||||
|
return documentService.batchMetadata(request.ids());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/incomplete-count")
|
@GetMapping("/incomplete-count")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public Map<String, Long> getIncompleteCount() {
|
public Map<String, Long> getIncompleteCount() {
|
||||||
|
|||||||
@@ -350,6 +350,22 @@ public class DocumentService {
|
|||||||
return documentRepository.save(doc);
|
return documentRepository.save(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns lightweight summaries (id, title, server PDF URL) for the given
|
||||||
|
* document IDs. Unknown IDs are silently dropped — the consumer is the
|
||||||
|
* bulk-edit page's left strip, where missing previews would already be
|
||||||
|
* obvious; surfacing them as errors here adds no value.
|
||||||
|
*/
|
||||||
|
public List<org.raddatz.familienarchiv.dto.DocumentBatchSummary> batchMetadata(List<UUID> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) return List.of();
|
||||||
|
return documentRepository.findAllById(ids).stream()
|
||||||
|
.map(d -> new org.raddatz.familienarchiv.dto.DocumentBatchSummary(
|
||||||
|
d.getId(),
|
||||||
|
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
|
||||||
|
"/api/documents/" + d.getId() + "/file"))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a bulk-edit DTO to a single document atomically.
|
* Applies a bulk-edit DTO to a single document atomically.
|
||||||
* Tags and receivers are additive (merged into existing sets); sender and the
|
* Tags and receivers are additive (merged into existing sets); sender and the
|
||||||
|
|||||||
@@ -1016,6 +1016,41 @@ class DocumentControllerTest {
|
|||||||
verify(documentService).applyBulkEditToDocument(eq(id2), any());
|
verify(documentService).applyBulkEditToDocument(eq(id2), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"ids\":[]}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(documentService.batchMetadata(any())).thenReturn(List.of(
|
||||||
|
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
|
||||||
|
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"ids\":[\"" + id + "\"]}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||||
|
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||||
|
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
||||||
|
|||||||
@@ -2089,6 +2089,58 @@ class DocumentServiceTest {
|
|||||||
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── batchMetadata ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returnsEmpty_whenIdsIsNull() {
|
||||||
|
assertThat(documentService.batchMetadata(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
|
||||||
|
assertThat(documentService.batchMetadata(List.of())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
Document d1 = Document.builder().id(id1).title("Brief 1").build();
|
||||||
|
Document d2 = Document.builder().id(id2).title("Brief 2").build();
|
||||||
|
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
|
||||||
|
|
||||||
|
var result = documentService.batchMetadata(List.of(id1, id2));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertThat(result.get(0).id()).isEqualTo(id1);
|
||||||
|
assertThat(result.get(0).title()).isEqualTo("Brief 1");
|
||||||
|
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_silentlyDropsUnknownIds() {
|
||||||
|
UUID known = UUID.randomUUID();
|
||||||
|
UUID missing = UUID.randomUUID();
|
||||||
|
Document d = Document.builder().id(known).title("Found").build();
|
||||||
|
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
|
||||||
|
|
||||||
|
var result = documentService.batchMetadata(List.of(known, missing));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).id()).isEqualTo(known);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
|
||||||
|
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
|
||||||
|
|
||||||
|
var result = documentService.batchMetadata(List.of(id));
|
||||||
|
|
||||||
|
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|||||||
Reference in New Issue
Block a user