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 8b17a0ef..cd03a22f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -18,6 +18,7 @@ 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.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.TagOperator; @@ -193,6 +194,7 @@ public class DocumentController { @RequirePermission(Permission.WRITE_ALL) public QuickUploadResult quickUpload( @RequestPart(value = "files", required = false) List files, + @RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata, Authentication authentication) { List created = new ArrayList<>(); List updated = new ArrayList<>(); @@ -202,14 +204,26 @@ public class DocumentController { return new QuickUploadResult(created, updated, errors); } + if (files.size() > 50) { + throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"); + } + if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > files.size()) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"); + } + UUID actorId = requireUserId(authentication); - for (MultipartFile file : files) { + long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum(); + + for (int i = 0; i < files.size(); i++) { + MultipartFile file = files.get(i); if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE")); continue; } try { - DocumentService.StoreResult result = documentService.storeDocument(file, actorId); + DocumentService.StoreResult result = metadata != null + ? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId) + : documentService.storeDocument(file, actorId); if (result.isNew()) { created.add(result.document()); } else { @@ -221,6 +235,10 @@ public class DocumentController { } } + log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}", + actorId, files.size(), totalBytes, metadata != null, + created.size(), updated.size(), errors.size()); + return new QuickUploadResult(created, updated, errors); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java new file mode 100644 index 00000000..93e17fbf --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Data +public class DocumentBatchMetadataDTO { + private List titles; + private UUID senderId; + private List receiverIds; + private LocalDate documentDate; + private String location; + private String tags; + private Boolean metadataComplete; +} 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 85cd7d2c..686c8627 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -109,6 +109,8 @@ public enum ErrorCode { // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, + /** Batch upload exceeds the maximum allowed file count per request. 400 */ + BATCH_TOO_LARGE, /** An unexpected server-side error occurred. 500 */ INTERNAL_ERROR, } 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 fbdec954..36575f28 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -7,6 +7,7 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO; 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.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; @@ -132,6 +133,43 @@ public class DocumentService { return new StoreResult(saved, isNew); } + @Transactional + public StoreResult storeDocumentWithBatchMetadata( + MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException { + StoreResult base = storeDocument(file, actorId); + Document doc = base.document(); + + if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) { + doc.setTitle(metadata.getTitles().get(fileIndex)); + } + if (metadata.getSenderId() != null) { + doc.setSender(personService.getById(metadata.getSenderId())); + } + if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) { + doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds()))); + } + if (metadata.getDocumentDate() != null) { + doc.setDocumentDate(metadata.getDocumentDate()); + } + if (metadata.getLocation() != null) { + doc.setLocation(metadata.getLocation()); + } + if (metadata.getMetadataComplete() != null) { + doc.setMetadataComplete(metadata.getMetadataComplete()); + } + if (metadata.getTags() != null && !metadata.getTags().isBlank()) { + List tagNames = Arrays.stream(metadata.getTags().split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).toList(); + UUID docId = doc.getId(); + updateDocumentTags(docId, tagNames); + doc = documentRepository.findById(docId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId)); + } + + Document saved = documentRepository.save(doc); + return new StoreResult(saved, base.isNew()); + } + @Transactional public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException { String filename = (file != null && !file.isEmpty()) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index d9bbe9d0..1cdd7673 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -23,7 +23,8 @@ spring: servlet: multipart: max-file-size: 50MB - max-request-size: 50MB + max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317 + file-size-threshold: 2KB mail: host: ${MAIL_HOST:} 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 eb4c6873..1981b1b2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -1,15 +1,17 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentVersion; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.security.PermissionAspect; -import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.DocumentVersionService; @@ -766,4 +768,133 @@ class DocumentControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.editorName").value("Otto")); } + + // ─── POST /api/documents/quick-upload — metadata part ──────────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception { + UUID senderId = UUID.randomUUID(); + Person sender = Person.builder().id(senderId).lastName("Müller").build(); + + Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build(); + Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build(); + Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build(); + + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any())) + .thenReturn(new DocumentService.StoreResult(doc1, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any())) + .thenReturn(new DocumentService.StoreResult(doc2, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any())) + .thenReturn(new DocumentService.StoreResult(doc3, true)); + + org.springframework.mock.web.MockMultipartFile f1 = + new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f2 = + new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f3 = + new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + ("{\"senderId\":\"" + senderId + "\"}").getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created.length()").value(3)) + .andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.updated").isEmpty()) + .andExpect(jsonPath("$.errors").isEmpty()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception { + UUID senderId = UUID.randomUUID(); + Person sender = Person.builder().id(senderId).lastName("Müller").build(); + Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build(); + + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any())) + .thenReturn(new DocumentService.StoreResult(existing, false)); + + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + ("{\"senderId\":\"" + senderId + "\"}").getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created").isEmpty()) + .andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.errors").isEmpty()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception { + Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build(); + Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build(); + Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build(); + + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any())) + .thenReturn(new DocumentService.StoreResult(docA, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any())) + .thenReturn(new DocumentService.StoreResult(docB, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any())) + .thenReturn(new DocumentService.StoreResult(docC, true)); + + org.springframework.mock.web.MockMultipartFile f1 = + new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f2 = + new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f3 = + new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + "{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created[0].title").value("Alpha")) + .andExpect(jsonPath("$.created[1].title").value("Beta")) + .andExpect(jsonPath("$.created[2].title").value("Gamma")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + org.springframework.mock.web.MockMultipartFile f1 = + new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f2 = + new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + "{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_returns400_whenBatchExceedsCap() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + var builder = multipart("/api/documents/quick-upload"); + for (int i = 0; i < 51; i++) { + builder.file(new org.springframework.mock.web.MockMultipartFile( + "files", "f" + i + ".pdf", "application/pdf", new byte[]{1})); + } + + mockMvc.perform(builder) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE")); + } }