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 ea943163..ab776822 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -18,7 +18,10 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.validation.annotation.Validated; +import org.raddatz.familienarchiv.dto.BulkEditError; +import org.raddatz.familienarchiv.dto.BulkEditResult; import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; +import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.TagOperator; @@ -237,6 +240,45 @@ public class DocumentController { return new QuickUploadResult(created, updated, errors); } + // --- BULK EDIT --- + + private static final int BULK_EDIT_MAX_IDS = 500; + + @PatchMapping("/bulk") + @RequirePermission(Permission.WRITE_ALL) + public BulkEditResult patchBulk( + @RequestBody DocumentBulkEditDTO dto, + Authentication authentication) { + if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required"); + } + if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) { + throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, + "Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size()); + } + + UUID actorId = requireUserId(authentication); + int updated = 0; + List errors = new ArrayList<>(); + + for (UUID id : dto.getDocumentIds()) { + try { + documentService.applyBulkEditToDocument(id, dto); + updated++; + } catch (DomainException e) { + errors.add(new BulkEditError(id, e.getMessage())); + } catch (Exception e) { + errors.add(new BulkEditError(id, "Internal error")); + log.warn("Bulk edit failed for document {}: {}", id, e.getMessage()); + } + } + + log.info("bulkEdit actor={} documentIds={} updated={} errors={}", + actorId, dto.getDocumentIds().size(), updated, errors.size()); + + return new BulkEditResult(updated, errors); + } + @GetMapping("/incomplete-count") @RequirePermission(Permission.WRITE_ALL) public Map getIncompleteCount() { 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 b80ed173..bc67dd21 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -929,4 +929,112 @@ class DocumentControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE")); } + + // ─── PATCH /api/documents/bulk ─────────────────────────────────────────── + + private static String bulkBody(String... uuids) { + StringBuilder sb = new StringBuilder("{\"documentIds\":["); + for (int i = 0; i < uuids.length; i++) { + if (i > 0) sb.append(","); + sb.append("\"").append(uuids[i]).append("\""); + } + sb.append("]}"); + return sb.toString(); + } + + @Test + void patchBulk_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bulkBody(UUID.randomUUID().toString()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void patchBulk_returns403_forReadAllUser() throws Exception { + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bulkBody(UUID.randomUUID().toString()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"documentIds\":[]}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + String[] ids = new String[501]; + for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString(); + + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bulkBody(ids))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchBulk_returns200_andCallsServiceForEachId() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + when(documentService.applyBulkEditToDocument(any(), any())) + .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build()); + + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bulkBody(id1.toString(), id2.toString()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updated").value(2)) + .andExpect(jsonPath("$.errors").isEmpty()); + + verify(documentService).applyBulkEditToDocument(eq(id1), any()); + verify(documentService).applyBulkEditToDocument(eq(id2), any()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + UUID okId = UUID.randomUUID(); + UUID badId = UUID.randomUUID(); + when(documentService.applyBulkEditToDocument(eq(okId), any())) + .thenAnswer(inv -> Document.builder().id(okId).build()); + when(documentService.applyBulkEditToDocument(eq(badId), any())) + .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound( + org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId)); + + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bulkBody(okId.toString(), badId.toString()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updated").value(1)) + .andExpect(jsonPath("$.errors[0].id").value(badId.toString())) + .andExpect(jsonPath("$.errors[0].message").value( + org.hamcrest.Matchers.containsString("not found"))); + } }