From 779ffaab552a698b3ffb3c219a3a70998a020c73 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:27:46 +0200 Subject: [PATCH 01/23] feat(bulk-edit): scaffold DTOs and BULK_EDIT_TOO_MANY_IDS error code Adds the request/response shapes for the upcoming PATCH /api/documents/bulk, POST /api/documents/batch-metadata, and the new error code for the 500-ID cap. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../dto/BatchMetadataRequest.java | 9 ++++++++ .../familienarchiv/dto/BulkEditError.java | 9 ++++++++ .../familienarchiv/dto/BulkEditResult.java | 9 ++++++++ .../dto/DocumentBatchSummary.java | 10 +++++++++ .../dto/DocumentBulkEditDTO.java | 21 +++++++++++++++++++ .../familienarchiv/exception/ErrorCode.java | 2 ++ 6 files changed, 60 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/BatchMetadataRequest.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditError.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditResult.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchSummary.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchMetadataRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchMetadataRequest.java new file mode 100644 index 00000000..47bc489b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchMetadataRequest.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.List; +import java.util.UUID; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record BatchMetadataRequest( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List ids) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditError.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditError.java new file mode 100644 index 00000000..2bde3fdd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditError.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.UUID; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record BulkEditError( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditResult.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditResult.java new file mode 100644 index 00000000..af8608b1 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditResult.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record BulkEditResult( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List errors) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchSummary.java new file mode 100644 index 00000000..aaea14a0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchSummary.java @@ -0,0 +1,10 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.UUID; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record DocumentBatchSummary( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java new file mode 100644 index 00000000..ad6d246e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java @@ -0,0 +1,21 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.List; +import java.util.UUID; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DocumentBulkEditDTO { + private List documentIds; + private List tagNames; + private UUID senderId; + private List receiverIds; + private String documentLocation; + private String archiveBox; + private String archiveFolder; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 686c8627..187b793d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -111,6 +111,8 @@ public enum ErrorCode { VALIDATION_ERROR, /** Batch upload exceeds the maximum allowed file count per request. 400 */ BATCH_TOO_LARGE, + /** Bulk edit request exceeds the per-request document ID cap. 400 */ + BULK_EDIT_TOO_MANY_IDS, /** An unexpected server-side error occurred. 500 */ INTERNAL_ERROR, } -- 2.49.1 From a59feec81a2c90aab5da50b61931d6b99e0efe55 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:31:48 +0200 Subject: [PATCH 02/23] feat(bulk-edit): add DocumentService.applyBulkEditToDocument Per-document atomic mutation method for the upcoming bulk PATCH endpoint. Tags and receivers merge additively into existing sets; sender and the three location fields replace only when the DTO field is non-blank. Wrapped in its own @Transactional so a per-document failure cannot partially mutate other documents in the outer batch loop. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../service/DocumentService.java | 46 +++++ .../service/DocumentServiceTest.java | 194 ++++++++++++++++++ 2 files changed, 240 insertions(+) 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 b7d50f99..f53a60b3 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,52 @@ public class DocumentService { return documentRepository.save(doc); } + /** + * Applies a bulk-edit DTO to a single document atomically. + * Tags and receivers are additive (merged into existing sets); sender and the + * three location fields are replace-on-non-blank (null/blank means "no change"). + * Wrapped in its own transaction so a failure on one document never partially + * mutates another in the batch loop. + */ + @Transactional + public Document applyBulkEditToDocument(UUID id, org.raddatz.familienarchiv.dto.DocumentBulkEditDTO dto) { + Document doc = documentRepository.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); + + if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) { + Set merged = new HashSet<>(doc.getTags()); + for (String name : dto.getTagNames()) { + String clean = name.trim(); + if (!clean.isEmpty()) { + merged.add(tagService.findOrCreate(clean)); + } + } + doc.setTags(merged); + } + + if (dto.getSenderId() != null) { + doc.setSender(personService.getById(dto.getSenderId())); + } + + if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) { + Set merged = new HashSet<>(doc.getReceivers()); + merged.addAll(personService.getAllById(dto.getReceiverIds())); + doc.setReceivers(merged); + } + + if (StringUtils.hasText(dto.getDocumentLocation())) { + doc.setDocumentLocation(dto.getDocumentLocation()); + } + if (StringUtils.hasText(dto.getArchiveBox())) { + doc.setArchiveBox(dto.getArchiveBox()); + } + if (StringUtils.hasText(dto.getArchiveFolder())) { + doc.setArchiveFolder(dto.getArchiveFolder()); + } + + return documentRepository.save(doc); + } + /** * Hilfsmethode: Erstellt Platzhalter (wird später vom Excel-Service genutzt) */ 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 4c34862b..3065391c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1917,4 +1917,198 @@ class DocumentServiceTest { .isInstanceOf(DomainException.class) .hasMessageContaining("titles"); } + + // ─── applyBulkEditToDocument ───────────────────────────────────────────── + + private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() { + return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO(); + } + + @Test + void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() { + UUID id = UUID.randomUUID(); + when(documentRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto())) + .isInstanceOf(DomainException.class) + .hasMessageContaining(id.toString()); + } + + @Test + void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() { + UUID id = UUID.randomUUID(); + Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build(); + Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build(); + Document doc = Document.builder().id(id).title("T") + .tags(new HashSet<>(Set.of(existing))) + .receivers(new HashSet<>()) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(tagService.findOrCreate("Kurrent")).thenReturn(added); + + var dto = bulkDto(); + dto.setTagNames(List.of("Kurrent")); + documentService.applyBulkEditToDocument(id, dto); + + assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added); + } + + @Test + void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() { + UUID id = UUID.randomUUID(); + Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build(); + Document doc = Document.builder().id(id).title("T") + .tags(new HashSet<>(Set.of(existing))) + .receivers(new HashSet<>()) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + documentService.applyBulkEditToDocument(id, bulkDto()); + + assertThat(doc.getTags()).containsExactly(existing); + verify(tagService, never()).findOrCreate(any()); + } + + @Test + void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() { + UUID id = UUID.randomUUID(); + Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build(); + Document doc = Document.builder().id(id).title("T") + .tags(new HashSet<>(Set.of(existing))) + .receivers(new HashSet<>()) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var dto = bulkDto(); + dto.setTagNames(List.of()); + documentService.applyBulkEditToDocument(id, dto); + + assertThat(doc.getTags()).containsExactly(existing); + verify(tagService, never()).findOrCreate(any()); + } + + @Test + void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() { + UUID id = UUID.randomUUID(); + UUID senderId = UUID.randomUUID(); + Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build(); + Person newSender = Person.builder().id(senderId).firstName("New").build(); + Document doc = Document.builder().id(id).title("T") + .sender(oldSender) + .receivers(new HashSet<>()) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(personService.getById(senderId)).thenReturn(newSender); + + var dto = bulkDto(); + dto.setSenderId(senderId); + documentService.applyBulkEditToDocument(id, dto); + + assertThat(doc.getSender()).isEqualTo(newSender); + } + + @Test + void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() { + UUID id = UUID.randomUUID(); + Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build(); + Document doc = Document.builder().id(id).title("T") + .sender(existing) + .receivers(new HashSet<>()) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + documentService.applyBulkEditToDocument(id, bulkDto()); + + assertThat(doc.getSender()).isEqualTo(existing); + verify(personService, never()).getById(any()); + } + + @Test + void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() { + UUID id = UUID.randomUUID(); + UUID newReceiverId = UUID.randomUUID(); + Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build(); + Person added = Person.builder().id(newReceiverId).firstName("New").build(); + Document doc = Document.builder().id(id).title("T") + .receivers(new HashSet<>(Set.of(existing))) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added)); + + var dto = bulkDto(); + dto.setReceiverIds(List.of(newReceiverId)); + documentService.applyBulkEditToDocument(id, dto); + + assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added); + } + + @Test + void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() { + UUID id = UUID.randomUUID(); + Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build(); + Document doc = Document.builder().id(id).title("T") + .receivers(new HashSet<>(Set.of(existing))) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var dto = bulkDto(); + dto.setReceiverIds(List.of()); + documentService.applyBulkEditToDocument(id, dto); + + assertThat(doc.getReceivers()).containsExactly(existing); + verify(personService, never()).getAllById(any()); + } + + @Test + void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("T") + .archiveBox("OldBox") + .archiveFolder("OldFolder") + .documentLocation("OldLocation") + .receivers(new HashSet<>()) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var dto = bulkDto(); + dto.setArchiveBox("NewBox"); + dto.setArchiveFolder("NewFolder"); + dto.setDocumentLocation("NewLocation"); + documentService.applyBulkEditToDocument(id, dto); + + assertThat(doc.getArchiveBox()).isEqualTo("NewBox"); + assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder"); + assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation"); + } + + @Test + void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("T") + .archiveBox("KeepBox") + .archiveFolder("KeepFolder") + .documentLocation("KeepLocation") + .receivers(new HashSet<>()) + .build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var dto = bulkDto(); + dto.setArchiveBox(" "); + dto.setArchiveFolder(""); + // documentLocation left null + documentService.applyBulkEditToDocument(id, dto); + + assertThat(doc.getArchiveBox()).isEqualTo("KeepBox"); + assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder"); + assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation"); + } } -- 2.49.1 From f0da033ec9caddc50b883fe599da82d81be05dd0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:34:52 +0200 Subject: [PATCH 03/23] feat(bulk-edit): add PATCH /api/documents/bulk endpoint WRITE_ALL-gated batch endpoint that applies a partial DTO to up to 500 documents per request. Per-document failures (DOCUMENT_NOT_FOUND, etc.) are collected into the response's errors[] without aborting the batch. Logs an audit line consistent with quickUpload. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 42 +++++++ .../controller/DocumentControllerTest.java | 108 ++++++++++++++++++ 2 files changed, 150 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 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"))); + } } -- 2.49.1 From d251806e723b766819f6f14c32f4e6b09bf569b1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:38:08 +0200 Subject: [PATCH 04/23] feat(bulk-edit): add POST /api/documents/batch-metadata endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../controller/DocumentController.java | 11 ++++ .../service/DocumentService.java | 16 ++++++ .../controller/DocumentControllerTest.java | 35 +++++++++++++ .../service/DocumentServiceTest.java | 52 +++++++++++++++++++ 4 files changed, 114 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 ab776822..5d45300a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -18,9 +18,11 @@ 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.BatchMetadataRequest; import org.raddatz.familienarchiv.dto.BulkEditError; import org.raddatz.familienarchiv.dto.BulkEditResult; import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; +import org.raddatz.familienarchiv.dto.DocumentBatchSummary; import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; @@ -279,6 +281,15 @@ public class DocumentController { return new BulkEditResult(updated, errors); } + @PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE) + @RequirePermission(Permission.READ_ALL) + public List 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") @RequirePermission(Permission.WRITE_ALL) public Map getIncompleteCount() { 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 f53a60b3..c5832b0b 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,22 @@ public class DocumentService { 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 batchMetadata(List 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. * Tags and receivers are additive (merged into existing sets); sender and 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 bc67dd21..a4075835 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,41 @@ class DocumentControllerTest { 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 @WithMockUser(authorities = "WRITE_ALL") void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception { 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 3065391c..034a30bf 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,58 @@ class DocumentServiceTest { 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 void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() { UUID id = UUID.randomUUID(); -- 2.49.1 From b662117e554c4b8c0f312cfcebfef95617d592e9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:40:56 +0200 Subject: [PATCH 05/23] 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 -- 2.49.1 From 660e34e01659be789233f8b400f33646e89c3383 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:52:10 +0200 Subject: [PATCH 06/23] feat(bulk-edit): add i18n keys, error mapping, and regenerate api types - 14 new Paraglide keys in de/en/es for the bulk-edit UI strings (selection bar, callout, badges, save progress, retry, error) - BULK_EDIT_TOO_MANY_IDS added to errors.ts type union and getErrorMessage() - Regenerated api.ts now includes /api/documents/{bulk,batch-metadata,ids} and the DocumentBulkEditDTO / BulkEditResult / DocumentBatchSummary schemas Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 16 ++- frontend/messages/en.json | 16 ++- frontend/messages/es.json | 16 ++- frontend/src/lib/errors.ts | 3 + frontend/src/lib/generated/api.ts | 157 +++++++++++++++++++++++++++++- 5 files changed, 204 insertions(+), 4 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index fe3ecb42..5642db93 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -873,5 +873,19 @@ "bulk_drop_zone_label": "Dateien ablegen", "bulk_remove_file": "Entfernen", "bulk_title_single": "Neues Dokument", - "bulk_title_multi": "Neue Dokumente" + "bulk_title_multi": "Neue Dokumente", + "bulk_edit_button": "Massenbearbeitung", + "bulk_edit_n_selected": "{count} Dokumente ausgewählt", + "bulk_edit_clear_all": "Alles aufheben", + "bulk_edit_all_x": "Alle {count} editieren", + "bulk_edit_select_document": "Dokument {title} auswählen", + "bulk_edit_hint": "Nur ausgefüllte Felder werden angewendet. Tags und Empfänger werden hinzugefügt, nicht ersetzt.", + "bulk_edit_badge_additive": "+ wird hinzugefügt", + "bulk_edit_badge_replace": "wird ersetzt", + "bulk_edit_save_progress": "Batch {done} von {total} verarbeitet", + "bulk_edit_save_partial": "{done} von {total} gespeichert", + "bulk_edit_retry": "Erneut versuchen", + "bulk_edit_title": "Massenbearbeitung", + "bulk_edit_save_button": "Anwenden", + "error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1699a911..50f2399f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -873,5 +873,19 @@ "bulk_drop_zone_label": "Drop files here", "bulk_remove_file": "Remove", "bulk_title_single": "New Document", - "bulk_title_multi": "New Documents" + "bulk_title_multi": "New Documents", + "bulk_edit_button": "Bulk edit", + "bulk_edit_n_selected": "{count} documents selected", + "bulk_edit_clear_all": "Clear all", + "bulk_edit_all_x": "Edit all {count}", + "bulk_edit_select_document": "Select document {title}", + "bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.", + "bulk_edit_badge_additive": "+ added", + "bulk_edit_badge_replace": "replaced", + "bulk_edit_save_progress": "Batch {done} of {total} processed", + "bulk_edit_save_partial": "{done} of {total} saved", + "bulk_edit_retry": "Retry", + "bulk_edit_title": "Bulk edit", + "bulk_edit_save_button": "Apply", + "error_bulk_edit_too_many_ids": "Maximum 500 documents per request." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 61fba34c..0cd956ab 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -873,5 +873,19 @@ "bulk_drop_zone_label": "Soltar archivos aquí", "bulk_remove_file": "Eliminar", "bulk_title_single": "Nuevo Documento", - "bulk_title_multi": "Nuevos Documentos" + "bulk_title_multi": "Nuevos Documentos", + "bulk_edit_button": "Edición masiva", + "bulk_edit_n_selected": "{count} documentos seleccionados", + "bulk_edit_clear_all": "Limpiar todo", + "bulk_edit_all_x": "Editar los {count}", + "bulk_edit_select_document": "Seleccionar documento {title}", + "bulk_edit_hint": "Solo se aplican los campos rellenados. Las etiquetas y los destinatarios se añaden, no se reemplazan.", + "bulk_edit_badge_additive": "+ se añade", + "bulk_edit_badge_replace": "se reemplaza", + "bulk_edit_save_progress": "Lote {done} de {total} procesado", + "bulk_edit_save_partial": "{done} de {total} guardado", + "bulk_edit_retry": "Reintentar", + "bulk_edit_title": "Edición masiva", + "bulk_edit_save_button": "Aplicar", + "error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud." } diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 1a1d1551..eec7a3e1 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -42,6 +42,7 @@ export type ErrorCode = | 'FORBIDDEN' | 'VALIDATION_ERROR' | 'BATCH_TOO_LARGE' + | 'BULK_EDIT_TOO_MANY_IDS' | 'INTERNAL_ERROR'; export interface BackendError { @@ -142,6 +143,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_validation_error(); case 'BATCH_TOO_LARGE': return m.error_batch_too_large(); + case 'BULK_EDIT_TOO_MANY_IDS': + return m.error_bulk_edit_too_many_ids(); default: return m.error_internal_error(); } diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 3c2e8036..81925006 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -484,6 +484,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/batch-metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["batchMetadata"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/reset-password": { parameters: { query?: never; @@ -676,6 +692,22 @@ export interface paths { patch: operations["updateAnnotation"]; trace?: never; }; + "/api/documents/bulk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["patchBulk"]; + trace?: never; + }; "/api/users/search": { parameters: { query?: never; @@ -1156,6 +1188,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDocumentIds"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/conversation": { parameters: { query?: never; @@ -1707,6 +1755,15 @@ export interface components { filename?: string; code?: string; }; + BatchMetadataRequest: { + ids: string[]; + }; + DocumentBatchSummary: { + /** Format: uuid */ + id: string; + title: string; + pdfUrl: string; + }; ResetPasswordRequest: { token?: string; newPassword?: string; @@ -1782,6 +1839,26 @@ export interface components { /** Format: double */ height?: number; }; + DocumentBulkEditDTO: { + documentIds?: string[]; + tagNames?: string[]; + /** Format: uuid */ + senderId?: string; + receiverIds?: string[]; + documentLocation?: string; + archiveBox?: string; + archiveFolder?: string; + }; + BulkEditError: { + /** Format: uuid */ + id: string; + message: string; + }; + BulkEditResult: { + /** Format: int32 */ + updated: number; + errors: components["schemas"]["BulkEditError"][]; + }; TranscriptionWeeklyStatsDTO: { /** Format: int64 */ segmentationCount: number; @@ -1833,7 +1910,6 @@ export interface components { /** Format: uuid */ id?: string; displayName?: string; - personType?: string; firstName?: string; lastName?: string; /** Format: int64 */ @@ -1844,6 +1920,7 @@ export interface components { deathYear?: number; alias?: string; notes?: string; + personType?: string; }; SenderModel: { /** Format: uuid */ @@ -3242,6 +3319,30 @@ export interface operations { }; }; }; + batchMetadata: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchMetadataRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DocumentBatchSummary"][]; + }; + }; + }; + }; resetPassword: { parameters: { query?: never; @@ -3578,6 +3679,30 @@ export interface operations { }; }; }; + patchBulk: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DocumentBulkEditDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BulkEditResult"]; + }; + }; + }; + }; search: { parameters: { query?: { @@ -4244,6 +4369,36 @@ export interface operations { }; }; }; + getDocumentIds: { + parameters: { + query?: { + q?: string; + from?: string; + to?: string; + senderId?: string; + receiverId?: string; + tag?: string[]; + tagQ?: string; + status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; + tagOp?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string[]; + }; + }; + }; + }; getConversation: { parameters: { query: { -- 2.49.1 From 25446c9a5c528d2a121042eb21382e24e7864f81 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 14:54:59 +0200 Subject: [PATCH 07/23] feat(bulk-edit): add bulkSelection store backed by SvelteSet Module-singleton live accumulator: selection persists across pagination and route changes within /documents and /enrich. Cleared on successful bulk save or via Alles aufheben. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../lib/stores/bulkSelection.svelte.spec.ts | 53 +++++++++++++++++++ .../src/lib/stores/bulkSelection.svelte.ts | 36 +++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 frontend/src/lib/stores/bulkSelection.svelte.spec.ts create mode 100644 frontend/src/lib/stores/bulkSelection.svelte.ts diff --git a/frontend/src/lib/stores/bulkSelection.svelte.spec.ts b/frontend/src/lib/stores/bulkSelection.svelte.spec.ts new file mode 100644 index 00000000..cde4814a --- /dev/null +++ b/frontend/src/lib/stores/bulkSelection.svelte.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { bulkSelectionStore } from './bulkSelection.svelte'; + +describe('bulkSelectionStore', () => { + afterEach(() => bulkSelectionStore.clear()); + + it('starts empty', () => { + expect(bulkSelectionStore.size).toBe(0); + }); + + it('toggle adds an id when absent', () => { + bulkSelectionStore.toggle('a'); + expect(bulkSelectionStore.has('a')).toBe(true); + expect(bulkSelectionStore.size).toBe(1); + }); + + it('toggle removes an id when present', () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.toggle('a'); + expect(bulkSelectionStore.has('a')).toBe(false); + }); + + it('add and remove update size', () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('b'); + expect(bulkSelectionStore.size).toBe(2); + bulkSelectionStore.remove('a'); + expect(bulkSelectionStore.size).toBe(1); + expect(bulkSelectionStore.has('b')).toBe(true); + }); + + it('add is idempotent', () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('a'); + expect(bulkSelectionStore.size).toBe(1); + }); + + it('setAll replaces the selection', () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('b'); + bulkSelectionStore.setAll(['c', 'd', 'e']); + expect(bulkSelectionStore.size).toBe(3); + expect(bulkSelectionStore.has('a')).toBe(false); + expect(bulkSelectionStore.has('c')).toBe(true); + }); + + it('clear empties the selection', () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('b'); + bulkSelectionStore.clear(); + expect(bulkSelectionStore.size).toBe(0); + }); +}); diff --git a/frontend/src/lib/stores/bulkSelection.svelte.ts b/frontend/src/lib/stores/bulkSelection.svelte.ts new file mode 100644 index 00000000..1d1f7863 --- /dev/null +++ b/frontend/src/lib/stores/bulkSelection.svelte.ts @@ -0,0 +1,36 @@ +import { SvelteSet } from 'svelte/reactivity'; + +// Live accumulator. Selection persists across pagination and route changes +// within /documents and /enrich. Cleared on successful bulk save or via +// "Alles aufheben". The store is module-singleton — there is only ever one +// bulk-edit selection per browser session. +const selectedIds = new SvelteSet(); + +export const bulkSelectionStore = { + get ids(): SvelteSet { + return selectedIds; + }, + get size(): number { + return selectedIds.size; + }, + has(id: string): boolean { + return selectedIds.has(id); + }, + toggle(id: string): void { + if (selectedIds.has(id)) selectedIds.delete(id); + else selectedIds.add(id); + }, + add(id: string): void { + selectedIds.add(id); + }, + remove(id: string): void { + selectedIds.delete(id); + }, + setAll(ids: Iterable): void { + selectedIds.clear(); + for (const id of ids) selectedIds.add(id); + }, + clear(): void { + selectedIds.clear(); + } +}; -- 2.49.1 From 27e3d290e790df617d3b9a4b72bb8ccb6e57cf8d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 15:03:59 +0200 Subject: [PATCH 08/23] feat(bulk-edit): add canWrite-gated row checkboxes on /documents and /enrich MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each row in the document search list and the enrichment queue gets a WCAG-compliant (44px touch target) checkbox bound to bulkSelectionStore. Checkbox click does not trigger the row's stretched-link navigation — it sits inside the z-10 content sibling, the link is in the z-0 sibling, so click events do not bubble between them. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/DocumentRow.svelte | 18 +++++++- .../lib/components/DocumentRow.svelte.spec.ts | 41 +++++++++++++++++++ frontend/src/routes/DocumentList.svelte | 2 +- frontend/src/routes/enrich/+page.server.ts | 2 +- frontend/src/routes/enrich/+page.svelte | 24 +++++++++-- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/DocumentRow.svelte b/frontend/src/lib/components/DocumentRow.svelte index d4e2bd5c..2b656a65 100644 --- a/frontend/src/lib/components/DocumentRow.svelte +++ b/frontend/src/lib/components/DocumentRow.svelte @@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api'; import { applyOffsets } from '$lib/search'; import { formatDate } from '$lib/utils/date'; import * as m from '$lib/paraglide/messages.js'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import ProgressRing from './ProgressRing.svelte'; import ContributorStack from './ContributorStack.svelte'; import DocumentThumbnail from './DocumentThumbnail.svelte'; type DocumentSearchItem = components['schemas']['DocumentSearchItem']; -let { item }: { item: DocumentSearchItem } = $props(); +let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props(); const doc = $derived(item.document); const titleText = $derived(doc.title || doc.originalFilename); @@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
+ + {#if canWrite} + + {/if} diff --git a/frontend/src/lib/components/DocumentRow.svelte.spec.ts b/frontend/src/lib/components/DocumentRow.svelte.spec.ts index 274062a6..f7d0b92a 100644 --- a/frontend/src/lib/components/DocumentRow.svelte.spec.ts +++ b/frontend/src/lib/components/DocumentRow.svelte.spec.ts @@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import { goto } from '$app/navigation'; import DocumentRow from './DocumentRow.svelte'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import type { components } from '$lib/generated/api'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); @@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() })); afterEach(() => { cleanup(); vi.mocked(goto).mockClear(); + bulkSelectionStore.clear(); }); type DocumentSearchItem = components['schemas']['DocumentSearchItem']; @@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => { }); }); +// ─── Bulk-selection checkbox ───────────────────────────────────────────────── + +describe('DocumentRow – bulk selection checkbox', () => { + it('does not render the checkbox when canWrite is false', async () => { + render(DocumentRow, { item: makeItem(), canWrite: false }); + await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument(); + }); + + it('renders the checkbox when canWrite is true', async () => { + render(DocumentRow, { item: makeItem(), canWrite: true }); + await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument(); + }); + + it('checkbox aria-label includes the document title', async () => { + const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } }); + render(DocumentRow, { item, canWrite: true }); + await expect + .element(page.getByRole('checkbox', { name: /Brief an Anna/i })) + .toBeInTheDocument(); + }); + + it('toggling the checkbox calls bulkSelectionStore.toggle', async () => { + const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } }); + render(DocumentRow, { item, canWrite: true }); + expect(bulkSelectionStore.has('doc-42')).toBe(false); + + document.querySelector('input[type="checkbox"]')?.click(); + + await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true); + }); + + it('checked state mirrors the store', async () => { + bulkSelectionStore.add('doc-99'); + const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } }); + render(DocumentRow, { item, canWrite: true }); + await expect.element(page.getByRole('checkbox')).toBeChecked(); + }); +}); + // ─── ProgressRing & ContributorStack ───────────────────────────────────────── describe('DocumentRow – progress ring and contributors', () => { diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 74478c66..d982a46a 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -119,7 +119,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
    {#each group.items as item (group.label + '-' + item.document.id)} - + {/each}
diff --git a/frontend/src/routes/enrich/+page.server.ts b/frontend/src/routes/enrich/+page.server.ts index 7cb4ea02..65d86b8f 100644 --- a/frontend/src/routes/enrich/+page.server.ts +++ b/frontend/src/routes/enrich/+page.server.ts @@ -19,5 +19,5 @@ export async function load({ const documents = result.response.ok ? (result.data ?? []) : []; - return { documents }; + return { documents, canWrite }; } diff --git a/frontend/src/routes/enrich/+page.svelte b/frontend/src/routes/enrich/+page.svelte index b2130465..e6457afd 100644 --- a/frontend/src/routes/enrich/+page.svelte +++ b/frontend/src/routes/enrich/+page.svelte @@ -1,11 +1,13 @@
@@ -61,8 +63,24 @@ const count = $derived(documents.length);
    {#each documents as doc (doc.id)} -
  • - +
  • + +
    + {#if canWrite} + + {/if}

    {doc.title} @@ -74,7 +92,7 @@ const count = $derived(documents.length); aria-hidden="true" class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70" /> - +

  • {/each}
-- 2.49.1 From d4f32ed5d482d0121ae9b2bfcef5804f10be2d9e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 15:07:26 +0200 Subject: [PATCH 09/23] feat(bulk-edit): add BulkSelectionBar and Alle-X-editieren fast path - BulkSelectionBar component: sticky bottom bar shown only when canWrite and selection is non-empty. Buttons meet WCAG 44px touch targets and iOS safe-area inset is honoured. - Bar mounted on /documents and /enrich. - Alle X editieren button on /documents replaces the selection with every UUID matching the active filter (via /api/documents/ids) and jumps to /documents/bulk-edit. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkSelectionBar.svelte | 46 +++++++++++++++ .../document/BulkSelectionBar.svelte.spec.ts | 49 ++++++++++++++++ frontend/src/routes/documents/+page.svelte | 57 +++++++++++++++++++ frontend/src/routes/enrich/+page.svelte | 2 + 4 files changed, 154 insertions(+) create mode 100644 frontend/src/lib/components/document/BulkSelectionBar.svelte create mode 100644 frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte b/frontend/src/lib/components/document/BulkSelectionBar.svelte new file mode 100644 index 00000000..5a16897c --- /dev/null +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte @@ -0,0 +1,46 @@ + + +{#if canWrite && count > 0} +
+ + {m.bulk_edit_n_selected({ count })} + +
+ + +
+
+{/if} diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts new file mode 100644 index 00000000..649220e1 --- /dev/null +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { goto } from '$app/navigation'; +import BulkSelectionBar from './BulkSelectionBar.svelte'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +afterEach(() => { + cleanup(); + vi.mocked(goto).mockClear(); + bulkSelectionStore.clear(); +}); + +describe('BulkSelectionBar', () => { + it('does not render when canWrite is false', async () => { + bulkSelectionStore.add('a'); + render(BulkSelectionBar, { canWrite: false }); + await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument(); + }); + + it('does not render when selection is empty', async () => { + render(BulkSelectionBar, { canWrite: true }); + await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument(); + }); + + it('renders with the current selection count', async () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('b'); + render(BulkSelectionBar, { canWrite: true }); + await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2'); + }); + + it('clear button empties the store', async () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('b'); + render(BulkSelectionBar, { canWrite: true }); + await page.getByTestId('bulk-clear-all').click(); + expect(bulkSelectionStore.size).toBe(0); + }); + + it('Massenbearbeitung navigates to /documents/bulk-edit', async () => { + bulkSelectionStore.add('a'); + render(BulkSelectionBar, { canWrite: true }); + await page.getByTestId('bulk-edit-open').click(); + expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit'); + }); +}); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 80159e25..4bc00f64 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -6,6 +6,8 @@ import { SvelteURLSearchParams } from 'svelte/reactivity'; import SearchFilterBar from '../SearchFilterBar.svelte'; import DocumentList from '../DocumentList.svelte'; import Pagination from '$lib/components/Pagination.svelte'; +import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import * as m from '$lib/paraglide/messages.js'; let { data } = $props(); @@ -138,6 +140,45 @@ $effect(() => { } }); +let editingAll = $state(false); + +/** + * Fast path: replace the current selection with every document matching the + * active filter (across all pages) and jump to the bulk-edit screen. The + * /api/documents/ids endpoint is uncapped — chunking happens at PATCH time + * inside the bulk-edit page's save handler. + */ +async function editAllMatching() { + if (editingAll) return; + editingAll = true; + try { + const params = buildSearchParams({ + q: data.q || '', + from: data.from || '', + to: data.to || '', + senderId: data.senderId || '', + receiverId: data.receiverId || '', + tags: data.tags || [], + sort: '', + dir: '', + tagQ: data.tagQ || '', + tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' + }); + params.delete('sort'); + params.delete('dir'); + const res = await fetch(`/api/documents/ids?${params.toString()}`); + if (!res.ok) { + editingAll = false; + return; + } + const ids: string[] = await res.json(); + bulkSelectionStore.setAll(ids); + await goto('/documents/bulk-edit'); + } finally { + editingAll = false; + } +} + // Keep local filter state in sync with server data after navigation completes. // Guard q: skip overwrite while the user is actively typing. $effect(() => { @@ -181,6 +222,20 @@ $effect(() => { onblur={() => (qFocused = false)} /> + {#if data.canWrite && data.totalElements > 0} +
+ +
+ {/if} + { + + diff --git a/frontend/src/routes/enrich/+page.svelte b/frontend/src/routes/enrich/+page.svelte index e6457afd..dce475a8 100644 --- a/frontend/src/routes/enrich/+page.svelte +++ b/frontend/src/routes/enrich/+page.svelte @@ -99,3 +99,5 @@ const canWrite = $derived(data.canWrite);
{/if}
+ + -- 2.49.1 From fa5dc43864e1cf9701c2bf55570cb45d015187af Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 15:16:06 +0200 Subject: [PATCH 10/23] feat(bulk-edit): extend BulkDocumentEditLayout with mode="edit" - New FieldLabelBadge component (additive / replace variants, WCAG AA contrast) - WhoWhenSection: hideDate prop, editMode prop renders badges next to sender and receivers, hides the meta_location field - DescriptionSection: editMode prop renders badges next to tags and archive fields; new bindable archiveBox / archiveFolder inputs only in editMode - PersonTypeahead: optional badge prop forwards to FieldLabelBadge - FileSwitcherStrip FileEntry: file is now optional, documentId added so edit-mode entries reference an existing document by UUID - BulkDocumentEditLayout: mode prop branches drop zone / read-only title / callout / save handler. Edit save chunks 500 IDs per PATCH, stops on chunk failure with retry, marks per-document errors as chips, clears the bulk selection store on full success. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PersonTypeahead.svelte | 5 +- .../document/BulkDocumentEditLayout.svelte | 287 +++++++++++++++--- .../BulkDocumentEditLayout.svelte.spec.ts | 187 ++++++++++++ .../document/DescriptionSection.svelte | 92 ++++-- .../document/FieldLabelBadge.svelte | 16 + .../document/FieldLabelBadge.svelte.spec.ts | 30 ++ .../document/FileSwitcherStrip.svelte | 6 +- .../components/document/WhoWhenSection.svelte | 97 +++--- 8 files changed, 610 insertions(+), 110 deletions(-) create mode 100644 frontend/src/lib/components/document/FieldLabelBadge.svelte create mode 100644 frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 815623da..0ac204ab 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/actions/clickOutside'; import { createTypeahead } from '$lib/hooks/useTypeahead.svelte'; +import FieldLabelBadge from './document/FieldLabelBadge.svelte'; type Person = components['schemas']['Person']; interface Props { @@ -18,6 +19,7 @@ interface Props { autofocus?: boolean; required?: boolean; restrictToCorrespondentsOf?: string; + badge?: 'additive' | 'replace'; onchange?: (value: string) => void; onfocused?: () => void; } @@ -34,6 +36,7 @@ let { autofocus = false, required = false, restrictToCorrespondentsOf, + badge, onchange, onfocused }: Props = $props(); @@ -116,7 +119,7 @@ function selectPerson(person: Person) { class={compact ? 'block text-xs font-bold tracking-wide text-ink-3 uppercase' : 'block text-sm font-medium text-ink-2'} - >{label}{#if required}*{/if}{label}{#if required}*{/if}{#if badge}{/if} diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 6ce93a07..d6f75d22 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -5,6 +5,7 @@ import { onDestroy, untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js'; import type { ConfirmService } from '$lib/services/confirm.svelte.js'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import BulkDropZone from './BulkDropZone.svelte'; import FileSwitcherStrip from './FileSwitcherStrip.svelte'; import type { FileEntry } from './FileSwitcherStrip.svelte'; @@ -19,6 +20,12 @@ import type { components } from '$lib/generated/api'; type Person = components['schemas']['Person']; +export type BulkEditEntry = { + documentId: string; + title: string; + pdfUrl: string; +}; + // Optional — not available in unit tests that don't provide CONFIRM_KEY context. let _confirmService: ConfirmService | null; try { @@ -28,13 +35,17 @@ try { } let { + mode = 'upload', initialSenderId = '', initialSenderName = '', - initialReceivers = [] + initialReceivers = [], + initialEditEntries = [] }: { + mode?: 'upload' | 'edit'; initialSenderId?: string; initialSenderName?: string; initialReceivers?: Person[]; + initialEditEntries?: BulkEditEntry[]; } = $props(); // --- File state --- @@ -42,12 +53,35 @@ let files = new SvelteMap(); let activeId = $state(null); let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined); let saving = $state(false); +// Partial-failure surface: when set, the last save aborted at chunk N of M. +let partialSaved = $state<{ done: number; total: number } | null>(null); // --- Shared metadata --- let senderId = $state(untrack(() => initialSenderId)); let selectedReceivers = $state(untrack(() => initialReceivers)); let dateIso = $state(''); let tags = $state([]); +// Bulk-edit only — replace-on-non-blank semantics. +let documentLocation = $state(''); +let archiveBox = $state(''); +let archiveFolder = $state(''); + +// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the +// fetch upstream in the route — by the time this layout mounts, the metadata +// has already been resolved into `initialEditEntries`. +if (mode === 'edit') { + for (const entry of untrack(() => initialEditEntries)) { + const id = entry.documentId; // reuse documentId as the local FileEntry key + files.set(id, { + id, + documentId: entry.documentId, + title: entry.title, + status: 'idle', + previewUrl: entry.pdfUrl + }); + if (!activeId) activeId = id; + } +} // --- Derived --- const isMulti = $derived(files.size >= 2); @@ -105,10 +139,8 @@ onDestroy(() => { } }); -// --- Save --- -async function save() { - if (saving) return; - saving = true; +// --- Save (upload mode) --- +async function saveUpload() { const entries = Array.from(files.values()); // 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF). const chunkSize = 10; @@ -122,7 +154,7 @@ async function save() { for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const formData = new FormData(); - chunk.forEach((entry) => formData.append('files', entry.file)); + chunk.forEach((entry) => entry.file && formData.append('files', entry.file)); const metadata = { titles: chunk.map((e) => e.title), senderId: senderId || null, @@ -143,8 +175,8 @@ async function save() { if (!res.ok || errorFilenames.size > 0) { hadErrors = true; for (const entry of chunk) { - // When backend names specific files, mark only those; otherwise mark all. - const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true; + const filename = entry.file?.name; + const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true; if (isError) { const e = files.get(entry.id); if (e) files.set(entry.id, { ...e, status: 'error' }); @@ -160,9 +192,97 @@ async function save() { } chunkProgress = { done: i + 1, total: chunks.length }; } - saving = false; if (!hadErrors) goto('/documents'); } + +// --- Save (edit mode) --- +async function saveBulkEdit() { + const entries = Array.from(files.values()); + const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x); + + // PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk + // failure so the user sees a deterministic "X of N saved" outcome. + const chunkSize = 500; + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += chunkSize) { + chunks.push(ids.slice(i, i + chunkSize)); + } + chunkProgress = { done: 0, total: chunks.length }; + partialSaved = null; + + const dto = { + tagNames: tags.map((t) => t.name), + senderId: senderId || null, + receiverIds: selectedReceivers.map((r) => r.id), + documentLocation: documentLocation || null, + archiveBox: archiveBox || null, + archiveFolder: archiveFolder || null + }; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + try { + const res = await fetch('/api/documents/bulk', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...dto, documentIds: chunk }) + }); + if (!res.ok) { + // Network/server failure: the chunk did not apply. Mark its entries + // as errored, surface partial-save state, and stop. + for (const id of chunk) { + const e = files.get(id); + if (e) files.set(id, { ...e, status: 'error' }); + } + partialSaved = { done: i, total: chunks.length }; + return; + } + const body = (await res.json().catch(() => null)) as { + updated: number; + errors: { id: string; message: string }[]; + } | null; + if (body && body.errors && body.errors.length > 0) { + for (const err of body.errors) { + const e = files.get(err.id); + if (e) files.set(err.id, { ...e, status: 'error' }); + } + } + } catch { + for (const id of chunk) { + const e = files.get(id); + if (e) files.set(id, { ...e, status: 'error' }); + } + partialSaved = { done: i, total: chunks.length }; + return; + } + chunkProgress = { done: i + 1, total: chunks.length }; + } + + const stillErrored = Array.from(files.values()).some((e) => e.status === 'error'); + if (!stillErrored) { + bulkSelectionStore.clear(); + goto('/documents'); + } +} + +async function save() { + if (saving) return; + saving = true; + try { + if (mode === 'edit') { + await saveBulkEdit(); + } else { + await saveUpload(); + } + } finally { + saving = false; + } +} + +async function retrySave() { + partialSaved = null; + await save(); +}
@@ -213,11 +333,11 @@ async function save() {
- {#if files.size === 0} - + {#if mode === 'upload' && files.size === 0} + - {:else} - + {:else if files.size > 0} +
{#if activeFile} @@ -243,22 +363,44 @@ async function save() { class:opacity-60={files.size === 0} class:pointer-events-none={files.size === 0} > + {#if mode === 'edit'} + +
+ {m.bulk_edit_hint()} +
+ {/if} + {#if isMulti} {#if activeFile} - + {#if mode === 'edit'} +
+ + {m.form_label_title()} + +

{activeFile.title}

+
+ {:else} + + {/if} {/if}
@@ -268,33 +410,51 @@ async function save() { bind:selectedReceivers={selectedReceivers} bind:dateIso={dateIso} initialSenderName={initialSenderName} + hideDate={mode === 'edit'} + editMode={mode === 'edit'} + /> + - {:else}
- + {#if mode === 'edit' && activeFile} +
+ + {m.form_label_title()} + +

{activeFile.title}

+
+ {:else} + + {/if}
- + + {/if} + + {#if partialSaved} + {/if}
diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts index 1b24627d..1f96216f 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts @@ -312,3 +312,190 @@ describe('BulkDocumentEditLayout', () => { ); }); }); + +// ─── mode="edit" ───────────────────────────────────────────────────────────── + +describe('BulkDocumentEditLayout — mode="edit"', () => { + const editEntry = (i: number) => ({ + documentId: `doc-${i}`, + title: `Brief ${i}`, + pdfUrl: `/api/documents/doc-${i}/file` + }); + + it('does not render the BulkDropZone in edit mode', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull(); + }); + + it('renders the onboarding callout with role=note in edit mode', async () => { + render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + const callout = page.getByTestId('bulk-edit-callout'); + await expect.element(callout).toBeInTheDocument(); + await expect.element(callout).toHaveAttribute('role', 'note'); + }); + + it('renders read-only title display (no input) in edit mode', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull(); + // Per-file ScopeCard absent at N=1 — title rendered in the single card + const titleInput = container.querySelector('input[type="text"][value="Brief 1"]'); + expect(titleInput).toBeNull(); + }); + + it('hides the date field via WhoWhenSection hideDate prop', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull(); + }); + + it('shows additive badge next to tags label', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull(); + }); + + it('shows replace badges next to sender and archive fields', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]'); + // sender + documentLocation + archiveBox + archiveFolder = 4 + expect(replaceBadges.length).toBeGreaterThanOrEqual(4); + }); + + it('shows the archiveBox and archiveFolder bulk-only inputs', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull(); + }); + + it('save calls PATCH /api/documents/bulk in edit mode', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ updated: 2, errors: [] }) + }); + vi.stubGlobal('fetch', mockFetch); + + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1), editEntry(2)] + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + expect(saveBtn).not.toBeNull(); + saveBtn.click(); + + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/documents/bulk'); + expect(init.method).toBe('PATCH'); + const body = JSON.parse(init.body); + expect(body.documentIds).toEqual(['doc-1', 'doc-2']); + }); + + it('chunks IDs into 500-sized PATCH requests', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ updated: 500, errors: [] }) + }); + vi.stubGlobal('fetch', mockFetch); + + const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i)); + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: entries + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 }); + expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500); + expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500); + expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100); + }); + + it('stops on chunk failure and shows the partial-failure alert with retry', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) }) + .mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) }); + vi.stubGlobal('fetch', mockFetch); + + const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i)); + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: entries + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor( + () => { + const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]'); + expect(alert).not.toBeNull(); + }, + { timeout: 5000 } + ); + // Should have called twice — chunks 0 and 1 — but not the third. + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(goto)).not.toHaveBeenCalled(); + }); + + it('marks per-document error chips when service returns errors[]', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + updated: 1, + errors: [{ id: 'doc-2', message: 'Sender not found' }] + }) + }) + ); + + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1), editEntry(2)] + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor( + () => { + const errorChip = container.querySelector( + '[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]' + ); + expect(errorChip).not.toBeNull(); + }, + { timeout: 3000 } + ); + }); +}); diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index 27e7442e..dcde5a7a 100644 --- a/frontend/src/lib/components/document/DescriptionSection.svelte +++ b/frontend/src/lib/components/document/DescriptionSection.svelte @@ -1,31 +1,45 @@
@@ -67,40 +81,78 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
-

{m.form_label_tags()}

+

+ {m.form_label_tags()} + {#if editMode}{/if} +

t.name).join(',')} />
- -
- - -
+ {#if !editMode} + +
+ + +
+ {/if} -
+
+ >{m.form_label_archive_location()} + {#if editMode}{/if} +

{m.form_helper_archive_location()}

+ + {#if editMode} + +
+ + +
+ + +
+ + +
+ {/if}
diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte b/frontend/src/lib/components/document/FieldLabelBadge.svelte new file mode 100644 index 00000000..ac59e552 --- /dev/null +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte @@ -0,0 +1,16 @@ + + + + {text} + diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts new file mode 100644 index 00000000..9895e0c0 --- /dev/null +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import FieldLabelBadge from './FieldLabelBadge.svelte'; + +afterEach(() => cleanup()); + +describe('FieldLabelBadge', () => { + it('renders the additive variant text', async () => { + render(FieldLabelBadge, { variant: 'additive' }); + await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument(); + await expect + .element(page.getByTestId('field-label-badge-additive')) + .toHaveTextContent('+ wird hinzugefügt'); + }); + + it('renders the replace variant text', async () => { + render(FieldLabelBadge, { variant: 'replace' }); + await expect + .element(page.getByTestId('field-label-badge-replace')) + .toHaveTextContent('wird ersetzt'); + }); + + it('uses text-gray-600 for WCAG-AA contrast on muted backgrounds', async () => { + render(FieldLabelBadge, { variant: 'replace' }); + await expect + .element(page.getByTestId('field-label-badge-replace')) + .toHaveClass(/text-gray-600/); + }); +}); diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte b/frontend/src/lib/components/document/FileSwitcherStrip.svelte index 8816d1a1..2ee272da 100644 --- a/frontend/src/lib/components/document/FileSwitcherStrip.svelte +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte @@ -4,7 +4,11 @@ import { m } from '$lib/paraglide/messages.js'; export interface FileEntry { id: string; - file: File; + /** Present in upload mode only. Edit mode entries reference an existing + * document by `documentId` and have no local file blob. */ + file?: File; + /** Present in edit mode only — the server-side document UUID being edited. */ + documentId?: string; title: string; status: 'idle' | 'error'; previewUrl: string; diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index 679ecc84..6bfb2bb7 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; +import FieldLabelBadge from './FieldLabelBadge.svelte'; import { isoToGerman, handleGermanDateInput } from '$lib/utils/date'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; @@ -16,7 +17,9 @@ let { initialLocation = '', initialSenderName = '', suggestedDateIso = '', - suggestedSenderName = '' + suggestedSenderName = '', + hideDate = false, + editMode = false }: { senderId?: string; selectedReceivers?: Person[]; @@ -26,6 +29,8 @@ let { initialSenderName?: string; suggestedDateIso?: string; suggestedSenderName?: string; + hideDate?: boolean; + editMode?: boolean; } = $props(); let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso))); @@ -56,60 +61,72 @@ $effect(() => {
- -
- - - - {#if dateInvalid} -

{m.form_date_error()}

- {/if} -
+ {#if !hideDate} + +
+ + + + {#if dateInvalid} +

{m.form_date_error()}

+ {/if} +
+ {/if} - +
-

{m.form_label_receivers()}

+

+ {m.form_label_receivers()} + {#if editMode}{/if} +

- -
- - -
+ {#if !editMode} + +
+ + +
+ {/if}
-- 2.49.1 From 6d3489d0358108f66375364483cf2c613500044c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 15:18:07 +0200 Subject: [PATCH 11/23] feat(bulk-edit): add /documents/bulk-edit route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server load redirects READ_ALL-only users (or unauthenticated) to /documents. Page load: onMount reads bulkSelectionStore — redirects to /documents when the store is empty, otherwise POSTs the IDs to /api/documents/batch-metadata and hands the resulting summaries to BulkDocumentEditLayout in mode="edit". Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../documents/bulk-edit/+page.server.ts | 10 ++++ .../routes/documents/bulk-edit/+page.svelte | 53 +++++++++++++++++++ .../documents/bulk-edit/page.server.spec.ts | 46 ++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 frontend/src/routes/documents/bulk-edit/+page.server.ts create mode 100644 frontend/src/routes/documents/bulk-edit/+page.svelte create mode 100644 frontend/src/routes/documents/bulk-edit/page.server.spec.ts diff --git a/frontend/src/routes/documents/bulk-edit/+page.server.ts b/frontend/src/routes/documents/bulk-edit/+page.server.ts new file mode 100644 index 00000000..33c845a0 --- /dev/null +++ b/frontend/src/routes/documents/bulk-edit/+page.server.ts @@ -0,0 +1,10 @@ +import { redirect } from '@sveltejs/kit'; + +export async function load({ locals }: { locals: App.Locals }) { + const canWrite = + locals.user?.groups?.some((g: { permissions: string[] }) => + g.permissions.includes('WRITE_ALL') + ) ?? false; + if (!canWrite) throw redirect(303, '/documents'); + return { canWrite }; +} diff --git a/frontend/src/routes/documents/bulk-edit/+page.svelte b/frontend/src/routes/documents/bulk-edit/+page.svelte new file mode 100644 index 00000000..b4018b8e --- /dev/null +++ b/frontend/src/routes/documents/bulk-edit/+page.svelte @@ -0,0 +1,53 @@ + + + + {m.bulk_edit_title()} – Familienarchiv + + +{#if loading} +
+{:else if error} +
+ {error} +
+{:else if entries.length > 0} + +{/if} diff --git a/frontend/src/routes/documents/bulk-edit/page.server.spec.ts b/frontend/src/routes/documents/bulk-edit/page.server.spec.ts new file mode 100644 index 00000000..9f52a1a0 --- /dev/null +++ b/frontend/src/routes/documents/bulk-edit/page.server.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { load } from './+page.server'; + +describe('/documents/bulk-edit +page.server.ts', () => { + it('redirects to /documents when user lacks WRITE_ALL', async () => { + const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } }; + try { + // @ts-expect-error — partial event shape sufficient for this guard + await load({ locals }); + throw new Error('expected redirect to be thrown'); + } catch (e) { + const err = e as { status?: number; location?: string }; + expect(err.status).toBe(303); + expect(err.location).toBe('/documents'); + } + }); + + it('redirects when user has no groups', async () => { + const locals = { user: { groups: [] } }; + try { + // @ts-expect-error — partial event shape sufficient for this guard + await load({ locals }); + throw new Error('expected redirect'); + } catch (e) { + expect((e as { status?: number }).status).toBe(303); + } + }); + + it('redirects when no user is logged in', async () => { + const locals = {}; + try { + // @ts-expect-error — partial event shape sufficient for this guard + await load({ locals }); + throw new Error('expected redirect'); + } catch (e) { + expect((e as { status?: number }).status).toBe(303); + } + }); + + it('returns canWrite=true for a WRITE_ALL user', async () => { + const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } }; + // @ts-expect-error — partial event shape sufficient for this guard + const result = await load({ locals }); + expect(result).toEqual({ canWrite: true }); + }); +}); -- 2.49.1 From f13f6351611b7377763b1ef1355a5d889a92519d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 15:30:18 +0200 Subject: [PATCH 12/23] test(bulk-edit): e2e coverage for selection bar and Massenbearbeitung flow Five Playwright scenarios on the bulk-edit feature: - sticky bar appears with count when checkboxes are toggled - Alles aufheben hides the bar - Massenbearbeitung navigates to /documents/bulk-edit and the edit-mode onboarding callout is rendered - direct navigation to /documents/bulk-edit with no selection redirects back - the same bar drives /enrich (skipped when the test DB has no incomplete docs) Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/bulk-edit.spec.ts | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 frontend/e2e/bulk-edit.spec.ts diff --git a/frontend/e2e/bulk-edit.spec.ts b/frontend/e2e/bulk-edit.spec.ts new file mode 100644 index 00000000..511ccd09 --- /dev/null +++ b/frontend/e2e/bulk-edit.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E coverage for the bulk metadata edit feature (issue #225). + * + * Assumptions: + * - Auth setup has run as the admin user (WRITE_ALL). + * - The backend exposes /api/documents/{bulk,batch-metadata,ids}. + * - At least two documents exist in the search list at /documents. + */ + +test.describe('Bulk metadata edit', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/documents'); + await page.waitForSelector('[data-hydrated]'); + }); + + test('checking two documents shows the sticky selection bar with the count', async ({ page }) => { + const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]'); + await expect(checkboxes.first()).toBeVisible(); + await checkboxes.nth(0).check(); + await checkboxes.nth(1).check(); + + const bar = page.getByTestId('bulk-selection-bar'); + await expect(bar).toBeVisible(); + await expect(page.getByTestId('bulk-selection-count')).toContainText('2'); + }); + + test('Alles aufheben hides the bar', async ({ page }) => { + const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]'); + await checkboxes.nth(0).check(); + await expect(page.getByTestId('bulk-selection-bar')).toBeVisible(); + + await page.getByTestId('bulk-clear-all').click(); + await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible(); + }); + + test('Massenbearbeitung navigates to bulk-edit with the selected documents', async ({ page }) => { + const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]'); + await checkboxes.nth(0).check(); + await checkboxes.nth(1).check(); + await page.getByTestId('bulk-edit-open').click(); + + await page.waitForURL('**/documents/bulk-edit'); + // Onboarding callout is the surest indicator the edit-mode layout rendered. + await expect(page.getByTestId('bulk-edit-callout')).toBeVisible(); + }); + + test('navigating to /documents/bulk-edit with no selection redirects back to /documents', async ({ + page + }) => { + // Navigate directly without checking anything first. + await page.goto('/documents/bulk-edit'); + await page.waitForURL('**/documents'); + expect(page.url()).toMatch(/\/documents(\?|$)/); + }); + + test('the same selection bar drives the /enrich page', async ({ page }) => { + await page.goto('/enrich'); + await page.waitForSelector('[data-hydrated]'); + + // /enrich may legitimately be empty if every doc has metadata. In that + // case there's nothing to bulk-select; skip. + const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]'); + const count = await checkboxes.count(); + test.skip(count === 0, 'No incomplete documents available on /enrich'); + + await checkboxes.first().check(); + await expect(page.getByTestId('bulk-selection-bar')).toBeVisible(); + await expect(page.getByTestId('bulk-selection-count')).toContainText('1'); + + await page.getByTestId('bulk-clear-all').click(); + await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible(); + }); +}); -- 2.49.1 From 2bb8fb896837cf64874f5843e7027bf99ecde333 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 16:14:53 +0200 Subject: [PATCH 13/23] fix(bulk-edit): align BulkEditEntry shape with backend DocumentBatchSummary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production bug — the backend serialises the document UUID as `id`, but BulkEditEntry typed it as `documentId`. The runtime cast in /documents/ bulk-edit/+page.svelte was a TypeScript lie: every `entry.documentId` became undefined, the SvelteMap collapsed all selections under the undefined key, and the PATCH fired with `documentIds: []` (which the controller correctly rejected with 400). Field semantics ACs could therefore never fire end-to-end. Renamed `BulkEditEntry.documentId` → `id`. The FileEntry built from each summary still carries both `id` (local map key) and `documentId` (PATCH payload) so the save handler is unchanged. Reported by Elicit (B1) on PR #331. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 16 ++++++++++------ .../BulkDocumentEditLayout.svelte.spec.ts | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index d6f75d22..8aceb7c2 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -20,8 +20,13 @@ import type { components } from '$lib/generated/api'; type Person = components['schemas']['Person']; +// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route +// passes the parsed `/api/documents/batch-metadata` response straight in, so +// the field names must match what the backend actually serializes (id, not +// documentId). The FileEntry built from each summary still uses both `id` and +// `documentId` so the save handler can drive the PATCH payload by UUID. export type BulkEditEntry = { - documentId: string; + id: string; title: string; pdfUrl: string; }; @@ -71,15 +76,14 @@ let archiveFolder = $state(''); // has already been resolved into `initialEditEntries`. if (mode === 'edit') { for (const entry of untrack(() => initialEditEntries)) { - const id = entry.documentId; // reuse documentId as the local FileEntry key - files.set(id, { - id, - documentId: entry.documentId, + files.set(entry.id, { + id: entry.id, + documentId: entry.id, title: entry.title, status: 'idle', previewUrl: entry.pdfUrl }); - if (!activeId) activeId = id; + if (!activeId) activeId = entry.id; } } diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts index 1f96216f..ea7306e2 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts @@ -317,7 +317,7 @@ describe('BulkDocumentEditLayout', () => { describe('BulkDocumentEditLayout — mode="edit"', () => { const editEntry = (i: number) => ({ - documentId: `doc-${i}`, + id: `doc-${i}`, title: `Brief ${i}`, pdfUrl: `/api/documents/doc-${i}/file` }); -- 2.49.1 From 5cbb14d4a3403d8785f1e343d0e1dd2b1f7804ec Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 16:24:03 +0200 Subject: [PATCH 14/23] =?UTF-8?q?fix(bulk-edit):=20backend=20hardening=20?= =?UTF-8?q?=E2=80=94=20audit,=20caps,=20dedupe,=20CRLF,=20WRITE=5FALL=20on?= =?UTF-8?q?=20/ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Markus B1+B2, Nora C1+C4+C5, Tobias #1, Sara B1+B2+C2, Elicit S2+C4 from the cycle 1 review on PR #331. Audit / version trail applyBulkEditToDocument now takes actorId, calls documentVersionService.recordVersion(saved), and emits an AuditKind.METADATA_UPDATED event tagged source=BULK_EDIT — restoring parity with the single-doc updateDocument path. Caps /api/documents/batch-metadata: 500-ID cap (matches PATCH cap) /api/documents/ids: 5000 result cap with BULK_EDIT_TOO_MANY_IDS on overflow Permission tightening /api/documents/ids re-gated WRITE_ALL — its only consumer is the bulk-edit fast path (least-privilege per Elicit S2 + Nora's defence-in-depth). Audit log /ids and /batch-metadata now emit one log.info per call, mirroring the quickUpload + bulkEdit format. Robustness Duplicates in PATCH documentIds are de-duplicated via LinkedHashSet so a double-clicked "Alle X editieren" cannot inflate the updated count. log.warn lines that interpolate Throwable.getMessage() now run through a CRLF-strip helper (CWE-117). Tests added applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit patchBulk_acceptsExactly500Ids_atTheCap (off-by-one fence) patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount getDocumentIds_returns403_forUserWithoutWriteAll getDocumentIds_returns400_whenResultExceedsFilterCap batchMetadata_returns403_forUserWithoutReadAll batchMetadata_returns400_whenIdsExceedsCap All 231 backend tests green. Refs #225, PR #331 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 50 +++++++-- .../service/DocumentService.java | 20 +++- .../controller/DocumentControllerTest.java | 105 ++++++++++++++++-- .../service/DocumentServiceTest.java | 38 +++++-- 4 files changed, 183 insertions(+), 30 deletions(-) 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 5dc6a591..bb725202 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -245,6 +245,11 @@ public class DocumentController { // --- BULK EDIT --- private static final int BULK_EDIT_MAX_IDS = 500; + /** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered + * call from materialising the entire {@code documents} table into JSON. + * Generous enough for real-world "Alle X editieren" against the family + * archive's bounded scale (~1500 docs today, expected growth to ~5k). */ + private static final int BULK_EDIT_FILTER_MAX_IDS = 5000; @PatchMapping("/bulk") @RequirePermission(Permission.WRITE_ALL) @@ -263,26 +268,37 @@ public class DocumentController { int updated = 0; List errors = new ArrayList<>(); - for (UUID id : dto.getDocumentIds()) { + // Dedupe duplicate document IDs while preserving submission order. A + // double-click on "Alle X editieren" would otherwise hit each document + // twice and inflate the `updated` count returned to the user. + java.util.LinkedHashSet uniqueIds = new java.util.LinkedHashSet<>(dto.getDocumentIds()); + + for (UUID id : uniqueIds) { try { - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, actorId); updated++; } catch (DomainException e) { - errors.add(new BulkEditError(id, e.getMessage())); + errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage()))); } catch (Exception e) { errors.add(new BulkEditError(id, "Internal error")); - log.warn("Bulk edit failed for document {}: {}", id, e.getMessage()); + log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage())); } } - log.info("bulkEdit actor={} documentIds={} updated={} errors={}", - actorId, dto.getDocumentIds().size(), updated, errors.size()); + log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}", + actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size()); return new BulkEditResult(updated, errors); } + /** CRLF strip for any log line interpolating a free-form string (e.g. + * {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */ + private static String sanitizeForLog(String s) { + return s == null ? null : s.replaceAll("[\\r\\n]", "_"); + } + @GetMapping("/ids") - @RequirePermission(Permission.READ_ALL) + @RequirePermission(Permission.WRITE_ALL) public List getDocumentIds( @RequestParam(required = false) String q, @RequestParam(required = false) LocalDate from, @@ -292,17 +308,31 @@ public class DocumentController { @RequestParam(required = false, name = "tag") List tags, @RequestParam(required = false) String tagQ, @RequestParam(required = false) DocumentStatus status, - @RequestParam(required = false) String tagOp) { + @RequestParam(required = false) String tagOp, + Authentication authentication) { TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; - return documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator); + List ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator); + if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) { + throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, + "Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")"); + } + UUID actorId = requireUserId(authentication); + log.info("documentIds actor={} matched={}", actorId, ids.size()); + return ids; } @PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE) @RequirePermission(Permission.READ_ALL) - public List batchMetadata(@RequestBody BatchMetadataRequest request) { + public List batchMetadata(@RequestBody BatchMetadataRequest request, Authentication authentication) { if (request == null || request.ids() == null || request.ids().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required"); } + if (request.ids().size() > BULK_EDIT_MAX_IDS) { + throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, + "Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size()); + } + UUID actorId = requireUserId(authentication); + log.info("batchMetadata actor={} ids={}", actorId, request.ids().size()); return documentService.batchMetadata(request.ids()); } 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 0a4e52e4..c6d9e26a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -400,10 +400,20 @@ public class DocumentService { * Tags and receivers are additive (merged into existing sets); sender and the * three location fields are replace-on-non-blank (null/blank means "no change"). * Wrapped in its own transaction so a failure on one document never partially - * mutates another in the batch loop. + * mutates another in the controller's batch loop. + * + * Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit + * event tagged {@code source=BULK_EDIT} and writes a row to + * {@code document_versions} so the family archive's "who changed what" + * trail stays complete across both single- and bulk-doc edit paths. + * + * NOTE on N+1: tag and person resolution happens per-document. With 500 + * documents × 10 tags this fans out to ~5000 tag-resolve queries per + * request. Acceptable today because the family archive is bounded at + * ~1500 documents total. Tracked as a perf follow-up. */ @Transactional - public Document applyBulkEditToDocument(UUID id, org.raddatz.familienarchiv.dto.DocumentBulkEditDTO dto) { + public Document applyBulkEditToDocument(UUID id, org.raddatz.familienarchiv.dto.DocumentBulkEditDTO dto, UUID actorId) { Document doc = documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); @@ -438,7 +448,11 @@ public class DocumentService { doc.setArchiveFolder(dto.getArchiveFolder()); } - return documentRepository.save(doc); + Document saved = documentRepository.save(doc); + documentVersionService.recordVersion(saved); + auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), + Map.of("source", "BULK_EDIT")); + return saved; } /** 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 fed57756..19eadf2b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -996,13 +996,50 @@ class DocumentControllerTest { .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + when(documentService.applyBulkEditToDocument(any(), any(), any())) + .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build()); + + String[] ids = new String[500]; + for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString(); + + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bulkBody(ids))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updated").value(500)); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + UUID id = UUID.randomUUID(); + when(documentService.applyBulkEditToDocument(eq(id), any(), any())) + .thenAnswer(inv -> Document.builder().id(id).build()); + + // Same id sent three times — controller should dedupe and call the + // service exactly once, returning updated=1, not 3. + mockMvc.perform(patch("/api/documents/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bulkBody(id.toString(), id.toString(), id.toString()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updated").value(1)); + + verify(documentService, org.mockito.Mockito.times(1)) + .applyBulkEditToDocument(eq(id), any(), any()); + } + @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())) + when(documentService.applyBulkEditToDocument(any(), any(), any())) .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build()); mockMvc.perform(patch("/api/documents/bulk") @@ -1012,8 +1049,8 @@ class DocumentControllerTest { .andExpect(jsonPath("$.updated").value(2)) .andExpect(jsonPath("$.errors").isEmpty()); - verify(documentService).applyBulkEditToDocument(eq(id1), any()); - verify(documentService).applyBulkEditToDocument(eq(id2), any()); + verify(documentService).applyBulkEditToDocument(eq(id1), any(), any()); + verify(documentService).applyBulkEditToDocument(eq(id2), any(), any()); } // ─── GET /api/documents/ids ────────────────────────────────────────────── @@ -1025,8 +1062,18 @@ class DocumentControllerTest { } @Test - @WithMockUser(authorities = "READ_ALL") + @WithMockUser + void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception { + // /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X + // editieren" fast path; no other consumer needs it. + mockMvc.perform(get("/api/documents/ids")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") void getDocumentIds_returns200_andDelegatesToService() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); UUID id = UUID.randomUUID(); when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(List.of(id)); @@ -1037,8 +1084,9 @@ class DocumentControllerTest { } @Test - @WithMockUser(authorities = "READ_ALL") + @WithMockUser(authorities = "WRITE_ALL") void getDocumentIds_passesSenderIdParamToService() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); UUID senderId = UUID.randomUUID(); when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any())) .thenReturn(List.of()); @@ -1049,6 +1097,21 @@ class DocumentControllerTest { verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + // Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000). + java.util.List tooMany = new java.util.ArrayList<>(5001); + for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID()); + when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(tooMany); + + mockMvc.perform(get("/api/documents/ids")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); + } + // ─── POST /api/documents/batch-metadata ────────────────────────────────── @Test @@ -1059,6 +1122,15 @@ class DocumentControllerTest { .andExpect(status().isUnauthorized()); } + @Test + @WithMockUser + void batchMetadata_returns403_forUserWithoutReadAll() 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().isForbidden()); + } + @Test @WithMockUser(authorities = "READ_ALL") void batchMetadata_returns400_whenIdsEmpty() throws Exception { @@ -1068,9 +1140,28 @@ class DocumentControllerTest { .andExpect(status().isBadRequest()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void batchMetadata_returns400_whenIdsExceedsCap() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + StringBuilder sb = new StringBuilder("{\"ids\":["); + for (int i = 0; i < 501; i++) { + if (i > 0) sb.append(","); + sb.append("\"").append(UUID.randomUUID()).append("\""); + } + sb.append("]}"); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") + .contentType(MediaType.APPLICATION_JSON) + .content(sb.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); + } + @Test @WithMockUser(authorities = "READ_ALL") void batchMetadata_returnsSummaries_forExistingIds() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); UUID id = UUID.randomUUID(); when(documentService.batchMetadata(any())).thenReturn(List.of( new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file"))); @@ -1090,9 +1181,9 @@ class DocumentControllerTest { 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())) + when(documentService.applyBulkEditToDocument(eq(okId), any(), any())) .thenAnswer(inv -> Document.builder().id(okId).build()); - when(documentService.applyBulkEditToDocument(eq(badId), any())) + when(documentService.applyBulkEditToDocument(eq(badId), any(), any())) .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound( org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId)); 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 f90c725f..c8b270be 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1929,7 +1929,7 @@ class DocumentServiceTest { UUID id = UUID.randomUUID(); when(documentRepository.findById(id)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto())) + assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null)) .isInstanceOf(DomainException.class) .hasMessageContaining(id.toString()); } @@ -1949,7 +1949,7 @@ class DocumentServiceTest { var dto = bulkDto(); dto.setTagNames(List.of("Kurrent")); - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, null); assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added); } @@ -1965,7 +1965,7 @@ class DocumentServiceTest { when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - documentService.applyBulkEditToDocument(id, bulkDto()); + documentService.applyBulkEditToDocument(id, bulkDto(), null); assertThat(doc.getTags()).containsExactly(existing); verify(tagService, never()).findOrCreate(any()); @@ -1984,7 +1984,7 @@ class DocumentServiceTest { var dto = bulkDto(); dto.setTagNames(List.of()); - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, null); assertThat(doc.getTags()).containsExactly(existing); verify(tagService, never()).findOrCreate(any()); @@ -2006,7 +2006,7 @@ class DocumentServiceTest { var dto = bulkDto(); dto.setSenderId(senderId); - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, null); assertThat(doc.getSender()).isEqualTo(newSender); } @@ -2022,7 +2022,7 @@ class DocumentServiceTest { when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - documentService.applyBulkEditToDocument(id, bulkDto()); + documentService.applyBulkEditToDocument(id, bulkDto(), null); assertThat(doc.getSender()).isEqualTo(existing); verify(personService, never()).getById(any()); @@ -2043,7 +2043,7 @@ class DocumentServiceTest { var dto = bulkDto(); dto.setReceiverIds(List.of(newReceiverId)); - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, null); assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added); } @@ -2060,12 +2060,30 @@ class DocumentServiceTest { var dto = bulkDto(); dto.setReceiverIds(List.of()); - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, null); assertThat(doc.getReceivers()).containsExactly(existing); verify(personService, never()).getAllById(any()); } + @Test + void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() { + UUID id = UUID.randomUUID(); + UUID actorId = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.applyBulkEditToDocument(id, bulkDto(), actorId); + + verify(documentVersionService).recordVersion(doc); + verify(auditService).logAfterCommit( + eq(AuditKind.METADATA_UPDATED), + eq(actorId), + eq(id), + eq(java.util.Map.of("source", "BULK_EDIT"))); + } + @Test void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() { UUID id = UUID.randomUUID(); @@ -2082,7 +2100,7 @@ class DocumentServiceTest { dto.setArchiveBox("NewBox"); dto.setArchiveFolder("NewFolder"); dto.setDocumentLocation("NewLocation"); - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, null); assertThat(doc.getArchiveBox()).isEqualTo("NewBox"); assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder"); @@ -2183,7 +2201,7 @@ class DocumentServiceTest { dto.setArchiveBox(" "); dto.setArchiveFolder(""); // documentLocation left null - documentService.applyBulkEditToDocument(id, dto); + documentService.applyBulkEditToDocument(id, dto, null); assertThat(doc.getArchiveBox()).isEqualTo("KeepBox"); assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder"); -- 2.49.1 From 499beca124d4f3d4d00a7fd676a87ef71f802ba2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 16:29:44 +0200 Subject: [PATCH 15/23] fix(bulk-edit): drop dead initial-* props and clear store on edit-mode discard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix B1 — `WhoWhenSection.svelte:37` and `DescriptionSection.svelte:42` mutated $bindable props at top-level script scope, seeding them from `initial*` companion props that no caller ever passes. The pattern stomps parent-owned state in any future component re-evaluation. Removed the dead initialDateIso / initialLocation / initialDocumentLocation props and let the bindables carry their own initial value. dateDisplay and currentTitle now seed from the bindable directly inside untrack — no re-assignment required. Elicit B2 — In edit mode the file map IS the user's bulk selection, so discarding must clear bulkSelectionStore and bounce back to /documents, otherwise the user is left on /documents/bulk-edit with an empty form and a stale count in the bottom bar. Refs #225, PR #331 Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 10 ++++++++ .../BulkDocumentEditLayout.svelte.spec.ts | 24 +++++++++++++++++++ .../document/DescriptionSection.svelte | 9 ++----- .../components/document/WhoWhenSection.svelte | 10 +++----- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 8aceb7c2..d9576c2c 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -134,6 +134,16 @@ async function handleDiscard() { }); if (!ok) return; } + if (mode === 'edit') { + // In edit mode the file map IS the user's bulk selection — discarding + // must clear the upstream store and bounce back to the list, otherwise + // the user is left on /documents/bulk-edit with an empty form and a + // stale count in the bottom bar (issue #225 Bulk-Edit Panel table). + bulkSelectionStore.clear(); + discardAll(); + await goto('/documents'); + return; + } discardAll(); } diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts index ea7306e2..5fb57cac 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts @@ -315,6 +315,30 @@ describe('BulkDocumentEditLayout', () => { // ─── mode="edit" ───────────────────────────────────────────────────────────── +describe('BulkDocumentEditLayout — mode="edit" discard', () => { + it('discard in edit mode clears the selection store and navigates back to /documents', async () => { + const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte'); + bulkSelectionStore.setAll(['doc-1']); + + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [ + { id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' }, + { id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' } + ] + }); + + const discardBtn = container.querySelector( + 'button[data-testid="discard-all-btn"]' + ) as HTMLButtonElement; + expect(discardBtn).not.toBeNull(); + discardBtn.click(); + + await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 }); + expect(bulkSelectionStore.size).toBe(0); + }); +}); + describe('BulkDocumentEditLayout — mode="edit"', () => { const editEntry = (i: number) => ({ id: `doc-${i}`, diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index dcde5a7a..3714076f 100644 --- a/frontend/src/lib/components/document/DescriptionSection.svelte +++ b/frontend/src/lib/components/document/DescriptionSection.svelte @@ -11,7 +11,6 @@ let { archiveBox = $bindable(''), archiveFolder = $bindable(''), initialTitle = '', - initialDocumentLocation = '', initialSummary = '', titleRequired = false, suggestedTitle = '', @@ -24,7 +23,6 @@ let { archiveBox?: string; archiveFolder?: string; initialTitle?: string; - initialDocumentLocation?: string; initialSummary?: string; titleRequired?: boolean; suggestedTitle?: string; @@ -32,14 +30,11 @@ let { editMode?: boolean; } = $props(); +// currentTitle seeds from initialTitle once at mount; subsequent edits flow +// through the oninput handler that flips titleDirty. let titleDirty = $state(false); currentTitle = untrack(() => initialTitle); const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle); - -// Initialize controlled location field once from the legacy initial-* props so -// callers that haven't switched to the bindable form keep their existing -// pre-fill behaviour. -documentLocation = untrack(() => documentLocation || initialDocumentLocation);
diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index 6bfb2bb7..ed7e25d7 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -13,8 +13,6 @@ let { senderId = $bindable(''), selectedReceivers = $bindable([]), dateIso = $bindable(''), - initialDateIso = '', - initialLocation = '', initialSenderName = '', suggestedDateIso = '', suggestedSenderName = '', @@ -24,8 +22,6 @@ let { senderId?: string; selectedReceivers?: Person[]; dateIso?: string; - initialDateIso?: string; - initialLocation?: string; initialSenderName?: string; suggestedDateIso?: string; suggestedSenderName?: string; @@ -33,8 +29,9 @@ let { editMode?: boolean; } = $props(); -let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso))); -dateIso = untrack(() => initialDateIso); +// Seed dateDisplay from the bindable's current value once at mount; subsequent +// edits flow through handleDateInput which writes back to dateIso. +let dateDisplay = $state(untrack(() => isoToGerman(dateIso))); let dateDirty = $state(false); const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); @@ -122,7 +119,6 @@ $effect(() => { id="location" type="text" name="location" - value={initialLocation} placeholder={m.form_placeholder_location()} class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" /> -- 2.49.1 From 156efe8b312744daf5970f517bc064e223e3eb93 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 16:35:40 +0200 Subject: [PATCH 16/23] =?UTF-8?q?fix(bulk-edit):=20a11y=20+=20i18n=20harde?= =?UTF-8?q?ning=20(Leonie=20blockers=201=E2=80=934=20+=20quick=20concerns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 — i18n the archive-box / archive-folder labels and add helper text. Karton/Mappe were hardcoded German and broke EN/ES locales (WCAG 3.1.2). B2 — drop the hardcoded German aria-label on the onboarding callout. role="note" + the visible localised text is self-describing; the redundant label was overriding the translated content for AT users on EN/ES. B3 — Escape clears the bulk selection while the bar is visible. Adds an "Esc: Auswahl aufheben" hint visible at ≥ sm (WCAG 2.1.1). B4 — /documents and /enrich reserve pb-32 when the bulk-selection bar is visible so it doesn't occlude the last row or pagination (WCAG 1.4.10). Folded in three Leonie quick-concerns: - C5: badge text-[10px] → text-[11px], raw text-gray-600 → design-token text-ink-2 (dark-mode safe) - C7: aria-live="polite" on bulk-selection-count - C11: "Alles aufheben" → "Auswahl aufheben" (DE/EN/ES) — disambiguates from "discard the operation entirely" Refs #225, PR #331 Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 8 ++++- frontend/messages/en.json | 8 ++++- frontend/messages/es.json | 8 ++++- .../document/BulkDocumentEditLayout.svelte | 6 ++-- .../document/BulkSelectionBar.svelte | 34 +++++++++++++++---- .../document/BulkSelectionBar.svelte.spec.ts | 23 +++++++++++++ .../document/DescriptionSection.svelte | 10 +++--- .../document/FieldLabelBadge.svelte | 2 +- .../document/FieldLabelBadge.svelte.spec.ts | 6 ++-- frontend/src/routes/documents/+page.svelte | 8 ++++- frontend/src/routes/enrich/+page.svelte | 4 ++- 11 files changed, 95 insertions(+), 22 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5642db93..8088ef6b 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -887,5 +887,11 @@ "bulk_edit_retry": "Erneut versuchen", "bulk_edit_title": "Massenbearbeitung", "bulk_edit_save_button": "Anwenden", - "error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage." + "error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.", + "form_label_archive_box": "Karton", + "form_helper_archive_box": "Welcher Karton im Archiv?", + "form_label_archive_folder": "Mappe", + "form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?", + "bulk_edit_clear_selection": "Auswahl aufheben", + "bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 50f2399f..d5796dbf 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -887,5 +887,11 @@ "bulk_edit_retry": "Retry", "bulk_edit_title": "Bulk edit", "bulk_edit_save_button": "Apply", - "error_bulk_edit_too_many_ids": "Maximum 500 documents per request." + "error_bulk_edit_too_many_ids": "Maximum 500 documents per request.", + "form_label_archive_box": "Box", + "form_helper_archive_box": "Which box in the archive?", + "form_label_archive_folder": "Folder", + "form_helper_archive_folder": "Which folder inside the box?", + "bulk_edit_clear_selection": "Clear selection", + "bulk_edit_clear_hint_keyboard": "Esc: clear selection" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 0cd956ab..2678eee0 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -887,5 +887,11 @@ "bulk_edit_retry": "Reintentar", "bulk_edit_title": "Edición masiva", "bulk_edit_save_button": "Aplicar", - "error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud." + "error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.", + "form_label_archive_box": "Caja", + "form_helper_archive_box": "¿Qué caja del archivo?", + "form_label_archive_folder": "Carpeta", + "form_helper_archive_folder": "¿Qué carpeta dentro de la caja?", + "bulk_edit_clear_selection": "Limpiar selección", + "bulk_edit_clear_hint_keyboard": "Esc: limpiar selección" } diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index d9576c2c..57d21dd9 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -379,10 +379,12 @@ async function retrySave() { > {#if mode === 'edit'} + and that tags/receivers are added rather than replaced. + No aria-label — role=note + the visible text content is + self-describing; an aria-label would override that text for + AT users on non-DE locales. -->
diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte b/frontend/src/lib/components/document/BulkSelectionBar.svelte index 5a16897c..14e02c45 100644 --- a/frontend/src/lib/components/document/BulkSelectionBar.svelte +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte @@ -6,6 +6,7 @@ import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; let { canWrite }: { canWrite: boolean } = $props(); const count = $derived(bulkSelectionStore.size); +const visible = $derived(canWrite && count > 0); function openBulkEdit() { goto('/documents/bulk-edit'); @@ -14,16 +15,37 @@ function openBulkEdit() { function clearAll() { bulkSelectionStore.clear(); } + +// Escape clears the selection — keyboard escape hatch when the user has +// drilled into a 50-row selection and wants to bail without Tab-ing through +// the whole footer (WCAG 2.1.1). +function onEscape(e: KeyboardEvent) { + if (e.key === 'Escape' && visible) { + clearAll(); + } +} -{#if canWrite && count > 0} + + +{#if visible}
- - {m.bulk_edit_n_selected({ count })} - +
+ + {m.bulk_edit_n_selected({ count })} + + +
diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte b/frontend/src/lib/components/document/FieldLabelBadge.svelte index ac59e552..6a694dca 100644 --- a/frontend/src/lib/components/document/FieldLabelBadge.svelte +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte @@ -10,7 +10,7 @@ const text = $derived( {text} diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts index 9895e0c0..d52213ad 100644 --- a/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts @@ -21,10 +21,8 @@ describe('FieldLabelBadge', () => { .toHaveTextContent('wird ersetzt'); }); - it('uses text-gray-600 for WCAG-AA contrast on muted backgrounds', async () => { + it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => { render(FieldLabelBadge, { variant: 'replace' }); - await expect - .element(page.getByTestId('field-label-badge-replace')) - .toHaveClass(/text-gray-600/); + await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/); }); }); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 4bc00f64..82effc6f 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -200,7 +200,13 @@ $effect(() => { {m.nav_documents()} – Familienarchiv -
+ +
0 && data.canWrite} +>

{m.nav_documents()}

-
+ +
0 && canWrite}> -- 2.49.1 From 92d623e298e9b25a25bfac0a28259ac2a7a3c0f9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 16:41:06 +0200 Subject: [PATCH 17/23] chore(bulk-edit): bean validation on DTO, readOnly tx, imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tobias C2 — DocumentBulkEditDTO carries @Size guards on tagNames (max 200 entries × 200 chars), receiverIds (max 200), and the three location strings (max 255 chars each). Controller now uses @Valid on @RequestBody so they fire. The 500-cap on documentIds stays as a controller-level check (typed BULK_EDIT_TOO_MANY_IDS code, not generic VALIDATION_ERROR). Markus #7 — replace fully-qualified type names inside DocumentService with imports (DocumentBatchSummary, DocumentBulkEditDTO). Markus #8 — @Transactional(readOnly = true) on findIdsForFilter and batchMetadata. Both are pure read paths; the marker lets Hibernate skip dirty-checking on the loaded entities. Record conversion of DocumentBulkEditDTO (Markus #6 / Felix #3) deferred to a follow-up — keeping @Data avoids 10+ test bodies that mutate the DTO via setters; the inconsistency is documented in the DTO's class-level Javadoc. Refs #225, PR #331 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 3 +- .../dto/DocumentBulkEditDTO.java | 43 ++++++++++++++++++- .../service/DocumentService.java | 10 +++-- 3 files changed, 50 insertions(+), 6 deletions(-) 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 bb725202..a7867c92 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -13,6 +13,7 @@ import java.util.UUID; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; @@ -254,7 +255,7 @@ public class DocumentController { @PatchMapping("/bulk") @RequirePermission(Permission.WRITE_ALL) public BulkEditResult patchBulk( - @RequestBody DocumentBulkEditDTO dto, + @RequestBody @Valid DocumentBulkEditDTO dto, Authentication authentication) { if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required"); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java index ad6d246e..f7ec4a42 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java @@ -3,19 +3,58 @@ package org.raddatz.familienarchiv.dto; import java.util.List; import java.util.UUID; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; +/** + * Request body for {@code PATCH /api/documents/bulk}. Field semantics: + *
    + *
  • {@code tagNames} and {@code receiverIds} are additive — + * merged into each document's existing set, never replacing it.
  • + *
  • {@code senderId}, {@code documentLocation}, {@code archiveBox}, + * {@code archiveFolder} are replace-on-non-blank — null/blank + * fields are skipped, anything else overwrites.
  • + *
+ * + *

Kept as a Lombok {@code @Data} POJO (not a record) for symmetry with + * the existing {@code DocumentUpdateDTO} and to keep test setup terse — + * the per-feature DTOs introduced alongside this one ({@link BulkEditError}, + * {@link BulkEditResult}, {@link BatchMetadataRequest}, + * {@link DocumentBatchSummary}) are records because they have no + * test-side mutation. Tracked in the cycle-1 review for follow-up. + * + *

Bean-validation caps below defend against payload-amplification: the + * 1 MiB SvelteKit proxy cap allows ~26k UUIDs through to the backend, and + * Jetty's default body limit is 8 MB. {@code @Size} guards catch malformed + * clients without depending on those outer bounds. + */ @Data @NoArgsConstructor @AllArgsConstructor public class DocumentBulkEditDTO { + + // No @Size cap here on purpose: the controller's BULK_EDIT_MAX_IDS check + // returns the typed BULK_EDIT_TOO_MANY_IDS error code, which the frontend + // maps to a localised "Maximal 500 …" message via Paraglide. A bean- + // validation @Size would short-circuit that with a generic VALIDATION_ERROR. private List documentIds; - private List tagNames; + + @Size(max = 200, message = "tagNames must not exceed 200 entries") + private List<@Size(max = 200, message = "tagName must not exceed 200 chars") String> tagNames; + private UUID senderId; + + @Size(max = 200, message = "receiverIds must not exceed 200 entries") private List receiverIds; + + @Size(max = 255, message = "documentLocation must not exceed 255 chars") private String documentLocation; + + @Size(max = 255, message = "archiveBox must not exceed 255 chars") private String archiveBox; + + @Size(max = 255, message = "archiveFolder must not exceed 255 chars") private String archiveFolder; } 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 c6d9e26a..12ea8f4a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -8,6 +8,8 @@ import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; +import org.raddatz.familienarchiv.dto.DocumentBatchSummary; +import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO; import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; @@ -356,6 +358,7 @@ public class DocumentService { * frontend can replace the selection with every match across pages in one * round-trip. */ + @Transactional(readOnly = true) 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); @@ -385,10 +388,11 @@ public class DocumentService { * bulk-edit page's left strip, where missing previews would already be * obvious; surfacing them as errors here adds no value. */ - public List batchMetadata(List ids) { + @Transactional(readOnly = true) + public List batchMetadata(List ids) { if (ids == null || ids.isEmpty()) return List.of(); return documentRepository.findAllById(ids).stream() - .map(d -> new org.raddatz.familienarchiv.dto.DocumentBatchSummary( + .map(d -> new DocumentBatchSummary( d.getId(), d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(), "/api/documents/" + d.getId() + "/file")) @@ -413,7 +417,7 @@ public class DocumentService { * ~1500 documents total. Tracked as a perf follow-up. */ @Transactional - public Document applyBulkEditToDocument(UUID id, org.raddatz.familienarchiv.dto.DocumentBulkEditDTO dto, UUID actorId) { + public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) { Document doc = documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); -- 2.49.1 From 7df00859c63796802a6b1fe5b7c841bd76e3fcca Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 16:46:58 +0200 Subject: [PATCH 18/23] fix(bulk-edit): pluralization, edit-mode CTA, error UI, real loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elicit C1+C3 — bulk-selection count uses ICU-style plural keys (bulk_edit_n_selected_one / _other) so n=1 reads as "1 Dokument" instead of "1 Dokumente". Save CTA in edit mode reads "Anwenden" via the existing bulk_edit_save_button key; UploadSaveBar grew an editMode prop. Multi- chunk progress text is now visible (not aria-only). Felix C2 — bulk-edit page wires the backend error code through parseBackendError + getErrorMessage instead of falling back to a generic internal_error. Felix C5 — editAllMatching no longer swallows fetch failures: the button shows an inline error with the backend-mapped message (e.g. when the filter cap is exceeded). Leonie C8 — replace the literal "…" loading glyph on /documents/bulk-edit with a spinner + role=status + aria-live=polite + visible "Loading documents…" text. Leonie C9 — partial-failure card and bulk-edit page error card now use the design-system `text-danger` / `bg-danger/10` / `border-danger/40` tokens (dark-mode safe) instead of raw red palette values. Leonie C10 + C13 — German plural fixed; EN badges retensed ("+ added" → "+ will be added", "replaced" → "will replace") to match the future-tense intent of DE/ES. Refs #225, PR #331 Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 7 +++- frontend/messages/en.json | 11 ++++-- frontend/messages/es.json | 7 +++- .../document/BulkDocumentEditLayout.svelte | 3 +- .../document/BulkSelectionBar.svelte | 2 +- .../components/document/UploadSaveBar.svelte | 28 +++++++++++-- frontend/src/routes/documents/+page.svelte | 19 +++++++-- .../routes/documents/bulk-edit/+page.svelte | 39 +++++++++++++++++-- 8 files changed, 94 insertions(+), 22 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8088ef6b..cd9e60d8 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -875,7 +875,8 @@ "bulk_title_single": "Neues Dokument", "bulk_title_multi": "Neue Dokumente", "bulk_edit_button": "Massenbearbeitung", - "bulk_edit_n_selected": "{count} Dokumente ausgewählt", + "bulk_edit_n_selected_one": "1 Dokument ausgewählt", + "bulk_edit_n_selected_other": "{count} Dokumente ausgewählt", "bulk_edit_clear_all": "Alles aufheben", "bulk_edit_all_x": "Alle {count} editieren", "bulk_edit_select_document": "Dokument {title} auswählen", @@ -893,5 +894,7 @@ "form_label_archive_folder": "Mappe", "form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?", "bulk_edit_clear_selection": "Auswahl aufheben", - "bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben" + "bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben", + "bulk_edit_loading": "Dokumente werden geladen…", + "bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d5796dbf..9c505a67 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -875,13 +875,14 @@ "bulk_title_single": "New Document", "bulk_title_multi": "New Documents", "bulk_edit_button": "Bulk edit", - "bulk_edit_n_selected": "{count} documents selected", + "bulk_edit_n_selected_one": "1 document selected", + "bulk_edit_n_selected_other": "{count} documents selected", "bulk_edit_clear_all": "Clear all", "bulk_edit_all_x": "Edit all {count}", "bulk_edit_select_document": "Select document {title}", "bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.", - "bulk_edit_badge_additive": "+ added", - "bulk_edit_badge_replace": "replaced", + "bulk_edit_badge_additive": "+ will be added", + "bulk_edit_badge_replace": "will replace", "bulk_edit_save_progress": "Batch {done} of {total} processed", "bulk_edit_save_partial": "{done} of {total} saved", "bulk_edit_retry": "Retry", @@ -893,5 +894,7 @@ "form_label_archive_folder": "Folder", "form_helper_archive_folder": "Which folder inside the box?", "bulk_edit_clear_selection": "Clear selection", - "bulk_edit_clear_hint_keyboard": "Esc: clear selection" + "bulk_edit_clear_hint_keyboard": "Esc: clear selection", + "bulk_edit_loading": "Loading documents…", + "bulk_edit_all_x_failed": "Could not load filter results — please retry." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2678eee0..fdd5dd22 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -875,7 +875,8 @@ "bulk_title_single": "Nuevo Documento", "bulk_title_multi": "Nuevos Documentos", "bulk_edit_button": "Edición masiva", - "bulk_edit_n_selected": "{count} documentos seleccionados", + "bulk_edit_n_selected_one": "1 documento seleccionado", + "bulk_edit_n_selected_other": "{count} documentos seleccionados", "bulk_edit_clear_all": "Limpiar todo", "bulk_edit_all_x": "Editar los {count}", "bulk_edit_select_document": "Seleccionar documento {title}", @@ -893,5 +894,7 @@ "form_label_archive_folder": "Carpeta", "form_helper_archive_folder": "¿Qué carpeta dentro de la caja?", "bulk_edit_clear_selection": "Limpiar selección", - "bulk_edit_clear_hint_keyboard": "Esc: limpiar selección" + "bulk_edit_clear_hint_keyboard": "Esc: limpiar selección", + "bulk_edit_loading": "Cargando documentos…", + "bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo." } diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 57d21dd9..6dd18490 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -495,7 +495,7 @@ async function retrySave() {

diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte b/frontend/src/lib/components/document/BulkSelectionBar.svelte index 14e02c45..5cd06cc8 100644 --- a/frontend/src/lib/components/document/BulkSelectionBar.svelte +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte @@ -40,7 +40,7 @@ function onEscape(e: KeyboardEvent) { aria-live="polite" aria-atomic="true" > - {m.bulk_edit_n_selected({ count })} + {count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })}