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));