chore(bulk-edit): bean validation on DTO, readOnly tx, imports
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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:
|
||||
* <ul>
|
||||
* <li>{@code tagNames} and {@code receiverIds} are <b>additive</b> —
|
||||
* merged into each document's existing set, never replacing it.</li>
|
||||
* <li>{@code senderId}, {@code documentLocation}, {@code archiveBox},
|
||||
* {@code archiveFolder} are <b>replace-on-non-blank</b> — null/blank
|
||||
* fields are skipped, anything else overwrites.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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}) <i>are</i> records because they have no
|
||||
* test-side mutation. Tracked in the cycle-1 review for follow-up.
|
||||
*
|
||||
* <p>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<UUID> documentIds;
|
||||
private List<String> 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<UUID> 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;
|
||||
}
|
||||
|
||||
@@ -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<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
@@ -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<org.raddatz.familienarchiv.dto.DocumentBatchSummary> batchMetadata(List<UUID> ids) {
|
||||
@Transactional(readOnly = true)
|
||||
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
return documentRepository.findAllById(ids).stream()
|
||||
.map(d -> new org.raddatz.familienarchiv.dto.DocumentBatchSummary(
|
||||
.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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user